parent
8efdcab812
commit
d79581530b
@ -0,0 +1,66 @@ |
|||||||
|
-- Lua-side of the http module. |
||||||
|
-- http.request (stateless) and http.session (constructor) are provided by Rust |
||||||
|
-- before this runs. This layer adds resp.json(), method shorthands, the |
||||||
|
-- JSON/postJSON helpers, and a metatable for session objects. |
||||||
|
|
||||||
|
-- Attach resp.json() to a response table: parses resp.body via utils.fromJSON. |
||||||
|
local function wrap_resp(resp) |
||||||
|
resp.json = function() return utils.fromJSON(resp.body) end |
||||||
|
return resp |
||||||
|
end |
||||||
|
|
||||||
|
-- Wrap the stateless http.request so responses carry resp.json(). |
||||||
|
local _request = http.request |
||||||
|
http.request = function(method, url, opts) |
||||||
|
return wrap_resp(_request(method, url, opts)) |
||||||
|
end |
||||||
|
|
||||||
|
-- Method shorthands for the stateless module. |
||||||
|
for _, m in ipairs({ "get", "post", "put", "patch", "delete", "head" }) do |
||||||
|
http[m] = function(url, opts) return http.request(m:upper(), url, opts) end |
||||||
|
end |
||||||
|
|
||||||
|
function http.getJSON(url, opts) |
||||||
|
local resp = http.get(url, opts) |
||||||
|
return resp.json(), resp |
||||||
|
end |
||||||
|
|
||||||
|
function http.postJSON(url, body, opts) |
||||||
|
opts = opts or {} |
||||||
|
opts.json = body |
||||||
|
return http.post(url, opts) |
||||||
|
end |
||||||
|
|
||||||
|
-- Session metatable. http.session() returns a raw Rust table whose methods are |
||||||
|
-- _request, save, load, clearCookies and cookies. The metatable adds the |
||||||
|
-- request wrapper (for resp.json()) and the method shorthands on top. |
||||||
|
local session_mt = {} |
||||||
|
session_mt.__index = session_mt |
||||||
|
|
||||||
|
function session_mt:request(method, url, opts) |
||||||
|
return wrap_resp(self:_request(method, url, opts)) |
||||||
|
end |
||||||
|
|
||||||
|
function session_mt:get(url, opts) return self:request("GET", url, opts) end |
||||||
|
function session_mt:post(url, opts) return self:request("POST", url, opts) end |
||||||
|
function session_mt:put(url, opts) return self:request("PUT", url, opts) end |
||||||
|
function session_mt:patch(url, opts) return self:request("PATCH", url, opts) end |
||||||
|
function session_mt:delete(url, opts) return self:request("DELETE", url, opts) end |
||||||
|
function session_mt:head(url, opts) return self:request("HEAD", url, opts) end |
||||||
|
|
||||||
|
function session_mt:getJSON(url, opts) |
||||||
|
local resp = self:get(url, opts) |
||||||
|
return resp.json(), resp |
||||||
|
end |
||||||
|
|
||||||
|
function session_mt:postJSON(url, body, opts) |
||||||
|
opts = opts or {} |
||||||
|
opts.json = body |
||||||
|
return self:post(url, opts) |
||||||
|
end |
||||||
|
|
||||||
|
-- Wrap the Rust session constructor to install the metatable. |
||||||
|
local _session = http.session |
||||||
|
http.session = function(path) |
||||||
|
return setmetatable(_session(path), session_mt) |
||||||
|
end |
||||||
@ -0,0 +1,529 @@ |
|||||||
|
use std::collections::HashMap; |
||||||
|
use std::sync::{Arc, Mutex}; |
||||||
|
use std::time::Duration; |
||||||
|
|
||||||
|
use mlua::prelude::LuaResult; |
||||||
|
use mlua::{Lua, Table as LuaTable, Value as LuaValue}; |
||||||
|
|
||||||
|
use crate::stdlib::utils::lua_to_json; |
||||||
|
|
||||||
|
const HTTP_LUA: &str = include_str!("../../lua/stdlib/http.lua"); |
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cookie jar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A minimal in-memory cookie jar: bare domain (no leading dot) → name → value.
|
||||||
|
/// Deliberately simple — it covers the >95% case of `Set-Cookie` flows without
|
||||||
|
/// pulling in the `cookie_store` crate, and serializes cleanly to JSONL.
|
||||||
|
#[derive(Default)] |
||||||
|
struct CookieJar { |
||||||
|
cookies: HashMap<String, HashMap<String, String>>, |
||||||
|
} |
||||||
|
|
||||||
|
impl CookieJar { |
||||||
|
/// All cookies (name, value) whose stored domain matches `host`: either an
|
||||||
|
/// exact match or `host` being a subdomain of the stored domain.
|
||||||
|
fn cookies_for(&self, host: &str) -> Vec<(String, String)> { |
||||||
|
let mut out = Vec::new(); |
||||||
|
for (domain, names) in &self.cookies { |
||||||
|
let suffix = format!(".{domain}"); |
||||||
|
if host == domain || host.ends_with(&suffix) { |
||||||
|
for (name, value) in names { |
||||||
|
out.push((name.clone(), value.clone())); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
out |
||||||
|
} |
||||||
|
|
||||||
|
/// Parse a single `Set-Cookie` header value and store the cookie. Extracts
|
||||||
|
/// `name=value` (first segment) and an optional `Domain=` attribute, falling
|
||||||
|
/// back to the request host. Malformed headers are ignored.
|
||||||
|
fn set_from_header(&mut self, host: &str, header: &str) { |
||||||
|
let mut segments = header.split(';'); |
||||||
|
let first = match segments.next() { |
||||||
|
Some(s) => s.trim(), |
||||||
|
None => return, |
||||||
|
}; |
||||||
|
let (name, value) = match first.split_once('=') { |
||||||
|
Some((n, v)) => (n.trim(), v.trim()), |
||||||
|
None => return, |
||||||
|
}; |
||||||
|
if name.is_empty() { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
let mut domain = host.to_ascii_lowercase(); |
||||||
|
for seg in segments { |
||||||
|
if let Some((k, v)) = seg.split_once('=') |
||||||
|
&& k.trim().eq_ignore_ascii_case("domain") |
||||||
|
{ |
||||||
|
let d = v.trim().trim_start_matches('.').to_ascii_lowercase(); |
||||||
|
if !d.is_empty() { |
||||||
|
domain = d; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
self.cookies |
||||||
|
.entry(domain) |
||||||
|
.or_default() |
||||||
|
.insert(name.to_string(), value.to_string()); |
||||||
|
} |
||||||
|
|
||||||
|
/// Serialize as JSONL — one `{"domain","name","value"}` object per line.
|
||||||
|
fn to_jsonl(&self) -> String { |
||||||
|
let mut out = String::new(); |
||||||
|
for (domain, names) in &self.cookies { |
||||||
|
for (name, value) in names { |
||||||
|
let obj = serde_json::json!({ |
||||||
|
"domain": domain, |
||||||
|
"name": name, |
||||||
|
"value": value, |
||||||
|
}); |
||||||
|
out.push_str(&obj.to_string()); |
||||||
|
out.push('\n'); |
||||||
|
} |
||||||
|
} |
||||||
|
out |
||||||
|
} |
||||||
|
|
||||||
|
/// Merge cookies from JSONL produced by `to_jsonl`. Bad lines are skipped.
|
||||||
|
fn merge_jsonl(&mut self, src: &str) { |
||||||
|
for line in src.lines() { |
||||||
|
let line = line.trim(); |
||||||
|
if line.is_empty() { |
||||||
|
continue; |
||||||
|
} |
||||||
|
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { |
||||||
|
continue; |
||||||
|
}; |
||||||
|
let domain = v.get("domain").and_then(|x| x.as_str()); |
||||||
|
let name = v.get("name").and_then(|x| x.as_str()); |
||||||
|
let value = v.get("value").and_then(|x| x.as_str()); |
||||||
|
if let (Some(d), Some(n), Some(val)) = (domain, name, value) { |
||||||
|
self.cookies |
||||||
|
.entry(d.to_string()) |
||||||
|
.or_default() |
||||||
|
.insert(n.to_string(), val.to_string()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Request execution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `application/x-www-form-urlencoded` body from key/value pairs. Implemented
|
||||||
|
/// inline so the build needs no optional reqwest features.
|
||||||
|
fn form_urlencode(pairs: &[(String, String)]) -> String { |
||||||
|
fn encode(s: &str) -> String { |
||||||
|
let mut out = String::new(); |
||||||
|
for b in s.bytes() { |
||||||
|
match b { |
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { |
||||||
|
out.push(b as char) |
||||||
|
} |
||||||
|
b' ' => out.push('+'), |
||||||
|
_ => out.push_str(&format!("%{b:02X}")), |
||||||
|
} |
||||||
|
} |
||||||
|
out |
||||||
|
} |
||||||
|
pairs |
||||||
|
.iter() |
||||||
|
.map(|(k, v)| format!("{}={}", encode(k), encode(v))) |
||||||
|
.collect::<Vec<_>>() |
||||||
|
.join("&") |
||||||
|
} |
||||||
|
|
||||||
|
const MAX_REDIRECTS: u32 = 10; |
||||||
|
|
||||||
|
/// Shared by the stateless `http.request` and a session's `:request`. When `jar`
|
||||||
|
/// is `Some`, the matching jar cookies are sent and any `Set-Cookie` responses
|
||||||
|
/// are stored back.
|
||||||
|
///
|
||||||
|
/// Redirects are followed manually (the client is built with
|
||||||
|
/// `redirect::Policy::none`) so that `Set-Cookie` headers on 30x responses —
|
||||||
|
/// the common login → redirect → dashboard pattern — are captured into the jar,
|
||||||
|
/// which reqwest's transparent redirect following would otherwise hide.
|
||||||
|
async fn execute_request( |
||||||
|
lua: Lua, |
||||||
|
client: reqwest::Client, |
||||||
|
jar: Option<Arc<Mutex<CookieJar>>>, |
||||||
|
method: String, |
||||||
|
url: String, |
||||||
|
opts: Option<LuaTable>, |
||||||
|
) -> LuaResult<LuaTable> { |
||||||
|
let mut method = reqwest::Method::from_bytes(method.to_ascii_uppercase().as_bytes()) |
||||||
|
.map_err(|e| mlua::Error::external(format!("http: invalid method '{method}': {e}")))?; |
||||||
|
|
||||||
|
// Parse opts once into owned pieces so each redirect hop can rebuild the
|
||||||
|
// request (a reqwest RequestBuilder is single-use).
|
||||||
|
let mut timeout: Option<Duration> = None; |
||||||
|
let mut custom_headers: Vec<(String, String)> = Vec::new(); |
||||||
|
let mut opts_cookies: Vec<(String, String)> = Vec::new(); |
||||||
|
// Body, with the Content-Type it implies (None for a raw body).
|
||||||
|
let mut body: Option<(Vec<u8>, Option<&'static str>)> = None; |
||||||
|
// Auth: (is_digest, username, password).
|
||||||
|
let mut auth: Option<(bool, String, String)> = None; |
||||||
|
|
||||||
|
if let Some(opts) = opts.as_ref() { |
||||||
|
if let Some(t) = opts.get::<Option<f64>>("timeout")? { |
||||||
|
timeout = Some(Duration::try_from_secs_f64(t).map_err(|_| { |
||||||
|
mlua::Error::external( |
||||||
|
"http: timeout must be a non-negative finite number of seconds", |
||||||
|
) |
||||||
|
})?); |
||||||
|
} |
||||||
|
if let Some(headers) = opts.get::<Option<LuaTable>>("headers")? { |
||||||
|
for pair in headers.pairs::<String, String>() { |
||||||
|
custom_headers.push(pair?); |
||||||
|
} |
||||||
|
} |
||||||
|
if let Some(cookies) = opts.get::<Option<LuaTable>>("cookies")? { |
||||||
|
for pair in cookies.pairs::<String, String>() { |
||||||
|
opts_cookies.push(pair?); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Body: json > form > raw body (first one present wins).
|
||||||
|
if let Some(json_val) = opts.get::<Option<LuaValue>>("json")? { |
||||||
|
let json = lua_to_json(json_val, 0)?; |
||||||
|
let s = serde_json::to_string(&json).map_err(mlua::Error::external)?; |
||||||
|
body = Some((s.into_bytes(), Some("application/json"))); |
||||||
|
} else if let Some(form) = opts.get::<Option<LuaTable>>("form")? { |
||||||
|
let mut pairs = Vec::new(); |
||||||
|
for pair in form.pairs::<String, String>() { |
||||||
|
pairs.push(pair?); |
||||||
|
} |
||||||
|
body = Some(( |
||||||
|
form_urlencode(&pairs).into_bytes(), |
||||||
|
Some("application/x-www-form-urlencoded"), |
||||||
|
)); |
||||||
|
} else if let Some(b) = opts.get::<Option<mlua::String>>("body")? { |
||||||
|
body = Some((b.as_bytes().to_vec(), None)); |
||||||
|
} |
||||||
|
|
||||||
|
// Auth: { username, password, scheme = "basic" (default) | "digest" }.
|
||||||
|
if let Some(auth_tbl) = opts.get::<Option<LuaTable>>("auth")? { |
||||||
|
let username = auth_tbl |
||||||
|
.get::<Option<String>>("username")? |
||||||
|
.ok_or_else(|| mlua::Error::external("http: auth.username is required"))?; |
||||||
|
let password = auth_tbl |
||||||
|
.get::<Option<String>>("password")? |
||||||
|
.ok_or_else(|| mlua::Error::external("http: auth.password is required"))?; |
||||||
|
let is_digest = match auth_tbl.get::<Option<String>>("scheme")?.as_deref() { |
||||||
|
None | Some("basic") => false, |
||||||
|
Some("digest") => true, |
||||||
|
Some(other) => { |
||||||
|
return Err(mlua::Error::external(format!( |
||||||
|
"http: auth.scheme must be \"basic\" or \"digest\", got \"{other}\"" |
||||||
|
))); |
||||||
|
} |
||||||
|
}; |
||||||
|
auth = Some((is_digest, username, password)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let is_digest = matches!(auth.as_ref(), Some((true, _, _))); |
||||||
|
let mut digest_header: Option<String> = None; |
||||||
|
let mut digest_tried = false; |
||||||
|
|
||||||
|
let mut url = url; |
||||||
|
let mut redirects_left = MAX_REDIRECTS; |
||||||
|
|
||||||
|
let resp = loop { |
||||||
|
// Host used for cookie matching and as the Set-Cookie domain fallback;
|
||||||
|
// recomputed each hop since a redirect may cross hosts.
|
||||||
|
let host = reqwest::Url::parse(&url) |
||||||
|
.ok() |
||||||
|
.and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase())); |
||||||
|
|
||||||
|
let mut req = client.request(method.clone(), &url); |
||||||
|
|
||||||
|
if let Some(t) = timeout { |
||||||
|
req = req.timeout(t); |
||||||
|
} |
||||||
|
for (k, v) in &custom_headers { |
||||||
|
req = req.header(k, v); |
||||||
|
} |
||||||
|
|
||||||
|
// Cookie header: jar cookies for this host, then per-request cookies
|
||||||
|
// which override on name collision.
|
||||||
|
let mut cookie_map: HashMap<String, String> = HashMap::new(); |
||||||
|
if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) { |
||||||
|
for (n, v) in jar.lock().unwrap().cookies_for(host) { |
||||||
|
cookie_map.insert(n, v); |
||||||
|
} |
||||||
|
} |
||||||
|
for (k, v) in &opts_cookies { |
||||||
|
cookie_map.insert(k.clone(), v.clone()); |
||||||
|
} |
||||||
|
if !cookie_map.is_empty() { |
||||||
|
let header = cookie_map |
||||||
|
.iter() |
||||||
|
.map(|(k, v)| format!("{k}={v}")) |
||||||
|
.collect::<Vec<_>>() |
||||||
|
.join("; "); |
||||||
|
req = req.header(reqwest::header::COOKIE, header); |
||||||
|
} |
||||||
|
|
||||||
|
if let Some((bytes, ct)) = body.as_ref() { |
||||||
|
if let Some(ct) = ct { |
||||||
|
req = req.header(reqwest::header::CONTENT_TYPE, *ct); |
||||||
|
} |
||||||
|
req = req.body(bytes.clone()); |
||||||
|
} |
||||||
|
|
||||||
|
// Auth. Basic goes out on every hop; digest's Authorization is set only
|
||||||
|
// after the 401 challenge below has been answered (digest_header).
|
||||||
|
if let Some((digest, username, password)) = auth.as_ref() { |
||||||
|
if *digest { |
||||||
|
if let Some(h) = digest_header.as_ref() { |
||||||
|
req = req.header(reqwest::header::AUTHORIZATION, h); |
||||||
|
} |
||||||
|
} else { |
||||||
|
req = req.basic_auth(username, Some(password)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let resp = req |
||||||
|
.send() |
||||||
|
.await |
||||||
|
.map_err(|e| mlua::Error::external(format!("http: {e}")))?; |
||||||
|
let status = resp.status(); |
||||||
|
|
||||||
|
// Store Set-Cookie from this hop into the jar.
|
||||||
|
if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) { |
||||||
|
let mut j = jar.lock().unwrap(); |
||||||
|
for value in resp.headers().get_all(reqwest::header::SET_COOKIE).iter() { |
||||||
|
if let Ok(s) = value.to_str() { |
||||||
|
j.set_from_header(host, s); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Digest auth: answer a 401 challenge once, then retry the same request
|
||||||
|
// with the computed Authorization header.
|
||||||
|
if is_digest && !digest_tried && status == reqwest::StatusCode::UNAUTHORIZED { |
||||||
|
let challenge = resp |
||||||
|
.headers() |
||||||
|
.get(reqwest::header::WWW_AUTHENTICATE) |
||||||
|
.and_then(|v| v.to_str().ok()) |
||||||
|
.map(str::to_string); |
||||||
|
if let Some((_, username, password)) = auth.as_ref() |
||||||
|
&& let Some(challenge) = challenge |
||||||
|
&& let Ok(mut prompt) = digest_auth::parse(&challenge) |
||||||
|
{ |
||||||
|
let uri = reqwest::Url::parse(&url) |
||||||
|
.map(|u| match u.query() { |
||||||
|
Some(q) => format!("{}?{}", u.path(), q), |
||||||
|
None => u.path().to_string(), |
||||||
|
}) |
||||||
|
.unwrap_or_else(|_| url.clone()); |
||||||
|
let ctx = digest_auth::AuthContext::new_with_method( |
||||||
|
username.as_str(), |
||||||
|
password.as_str(), |
||||||
|
uri, |
||||||
|
body.as_ref().map(|(b, _)| b.as_slice()), |
||||||
|
digest_auth::HttpMethod::from(method.as_str()), |
||||||
|
); |
||||||
|
if let Ok(answer) = prompt.respond(&ctx) { |
||||||
|
digest_header = Some(answer.to_header_string()); |
||||||
|
digest_tried = true; |
||||||
|
continue; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Follow a redirect if there is one to follow.
|
||||||
|
if status.is_redirection() |
||||||
|
&& redirects_left > 0 |
||||||
|
&& let Some(next) = resp |
||||||
|
.headers() |
||||||
|
.get(reqwest::header::LOCATION) |
||||||
|
.and_then(|l| l.to_str().ok()) |
||||||
|
.and_then(|loc| reqwest::Url::parse(&url).and_then(|base| base.join(loc)).ok()) |
||||||
|
{ |
||||||
|
redirects_left -= 1; |
||||||
|
// 303, and 301/302 on a POST, degrade to a bodyless GET — the
|
||||||
|
// behaviour browsers and reqwest's own redirect policy apply.
|
||||||
|
let code = status.as_u16(); |
||||||
|
if code == 303 || ((code == 301 || code == 302) && method == reqwest::Method::POST) { |
||||||
|
method = reqwest::Method::GET; |
||||||
|
body = None; |
||||||
|
} |
||||||
|
url = next.to_string(); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
break resp; |
||||||
|
}; |
||||||
|
|
||||||
|
let status = resp.status().as_u16(); |
||||||
|
|
||||||
|
// Response headers: lowercase names, first value wins.
|
||||||
|
let headers_tbl = lua.create_table()?; |
||||||
|
for (name, value) in resp.headers().iter() { |
||||||
|
let lname = name.as_str().to_ascii_lowercase(); |
||||||
|
if !headers_tbl.contains_key(lname.as_str())? { |
||||||
|
headers_tbl.raw_set(lname.as_str(), lua.create_string(value.as_bytes())?)?; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let bytes = resp |
||||||
|
.bytes() |
||||||
|
.await |
||||||
|
.map_err(|e| mlua::Error::external(format!("http: {e}")))?; |
||||||
|
|
||||||
|
let out = lua.create_table()?; |
||||||
|
out.raw_set("status", status)?; |
||||||
|
out.raw_set("ok", (200..=299).contains(&status))?; |
||||||
|
out.raw_set("headers", headers_tbl)?; |
||||||
|
out.raw_set("body", lua.create_string(&bytes)?)?; |
||||||
|
Ok(out) |
||||||
|
} |
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Build the plain Lua table that backs a session. State (the cookie jar) lives
|
||||||
|
/// in the closures; the Lua layer applies a metatable for the method shorthands.
|
||||||
|
/// Methods are called as `s:method(...)`, so each closure receives the session
|
||||||
|
/// table as a leading `_this` argument that it ignores.
|
||||||
|
fn make_session( |
||||||
|
lua: &Lua, |
||||||
|
client: reqwest::Client, |
||||||
|
jar: Arc<Mutex<CookieJar>>, |
||||||
|
) -> LuaResult<LuaTable> { |
||||||
|
let tbl = lua.create_table()?; |
||||||
|
|
||||||
|
tbl.raw_set( |
||||||
|
"_request", |
||||||
|
lua.create_async_function({ |
||||||
|
let client = client.clone(); |
||||||
|
let jar = jar.clone(); |
||||||
|
move |lua, (_this, method, url, opts): (LuaTable, String, String, Option<LuaTable>)| { |
||||||
|
let client = client.clone(); |
||||||
|
let jar = jar.clone(); |
||||||
|
async move { |
||||||
|
execute_request(lua, client, Some(jar), method, url, opts).await |
||||||
|
} |
||||||
|
} |
||||||
|
})?, |
||||||
|
)?; |
||||||
|
|
||||||
|
tbl.raw_set("save", { |
||||||
|
let jar = jar.clone(); |
||||||
|
lua.create_function(move |_, (_this, path): (LuaTable, String)| { |
||||||
|
let jsonl = jar.lock().unwrap().to_jsonl(); |
||||||
|
std::fs::write(&path, jsonl) |
||||||
|
.map_err(|e| mlua::Error::external(format!("session:save: {e}"))) |
||||||
|
})? |
||||||
|
})?; |
||||||
|
|
||||||
|
tbl.raw_set("load", { |
||||||
|
let jar = jar.clone(); |
||||||
|
lua.create_function(move |_, (_this, path): (LuaTable, String)| { |
||||||
|
let src = std::fs::read_to_string(&path) |
||||||
|
.map_err(|e| mlua::Error::external(format!("session:load: {e}")))?; |
||||||
|
jar.lock().unwrap().merge_jsonl(&src); |
||||||
|
Ok(()) |
||||||
|
})? |
||||||
|
})?; |
||||||
|
|
||||||
|
tbl.raw_set("clearCookies", { |
||||||
|
let jar = jar.clone(); |
||||||
|
lua.create_function(move |_, _this: LuaTable| { |
||||||
|
jar.lock().unwrap().cookies.clear(); |
||||||
|
Ok(()) |
||||||
|
})? |
||||||
|
})?; |
||||||
|
|
||||||
|
tbl.raw_set("cookies", { |
||||||
|
let jar = jar.clone(); |
||||||
|
lua.create_function(move |lua, _this: LuaTable| { |
||||||
|
let outer = lua.create_table()?; |
||||||
|
let j = jar.lock().unwrap(); |
||||||
|
for (domain, names) in j.cookies.iter() { |
||||||
|
let inner = lua.create_table()?; |
||||||
|
for (name, value) in names.iter() { |
||||||
|
inner.raw_set(name.as_str(), value.as_str())?; |
||||||
|
} |
||||||
|
outer.raw_set(domain.as_str(), inner)?; |
||||||
|
} |
||||||
|
Ok(outer) |
||||||
|
})? |
||||||
|
})?; |
||||||
|
|
||||||
|
Ok(tbl) |
||||||
|
} |
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Installation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub(super) fn install(lua: &Lua) -> LuaResult<()> { |
||||||
|
// reqwest is built with `rustls-no-provider`, so it has no crypto provider
|
||||||
|
// of its own and panics ("No provider set") unless one is installed as the
|
||||||
|
// process default before the client is built. `ring` is self-contained
|
||||||
|
// (compiled in, no system OpenSSL). Idempotent across Lua states — only the
|
||||||
|
// first install in the process wins, and they would all install ring.
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default(); |
||||||
|
|
||||||
|
let client = reqwest::Client::builder() |
||||||
|
.timeout(Duration::from_secs(30)) |
||||||
|
// reqwest sends no User-Agent by default; some edges/CDNs reject
|
||||||
|
// empty-UA requests outright. A per-request `headers` entry overrides it.
|
||||||
|
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))) |
||||||
|
// Redirects are followed manually in execute_request so Set-Cookie
|
||||||
|
// headers on 30x responses can be captured into the cookie jar.
|
||||||
|
.redirect(reqwest::redirect::Policy::none()) |
||||||
|
.build() |
||||||
|
.map_err(mlua::Error::external)?; |
||||||
|
|
||||||
|
let http = lua.create_table()?; |
||||||
|
|
||||||
|
// Stateless http.request — no cookie jar.
|
||||||
|
http.raw_set( |
||||||
|
"request", |
||||||
|
lua.create_async_function({ |
||||||
|
let client = client.clone(); |
||||||
|
move |lua, (method, url, opts): (String, String, Option<LuaTable>)| { |
||||||
|
let client = client.clone(); |
||||||
|
async move { execute_request(lua, client, None, method, url, opts).await } |
||||||
|
} |
||||||
|
})?, |
||||||
|
)?; |
||||||
|
|
||||||
|
// http.session(path?) — constructor returning the raw session table.
|
||||||
|
http.raw_set( |
||||||
|
"session", |
||||||
|
lua.create_async_function({ |
||||||
|
let client = client.clone(); |
||||||
|
move |lua, path: Option<String>| { |
||||||
|
let client = client.clone(); |
||||||
|
async move { |
||||||
|
let mut jar = CookieJar::default(); |
||||||
|
if let Some(p) = path { |
||||||
|
let src = tokio::fs::read_to_string(&p) |
||||||
|
.await |
||||||
|
.map_err(|e| mlua::Error::external(format!("http.session: {e}")))?; |
||||||
|
jar.merge_jsonl(&src); |
||||||
|
} |
||||||
|
make_session(&lua, client, Arc::new(Mutex::new(jar))) |
||||||
|
} |
||||||
|
} |
||||||
|
})?, |
||||||
|
)?; |
||||||
|
|
||||||
|
lua.globals().raw_set("http", http)?; |
||||||
|
|
||||||
|
// Lua side adds: resp.json(), method shorthands, getJSON/postJSON, and the
|
||||||
|
// session metatable wrapping http.session().
|
||||||
|
lua.load(HTTP_LUA).set_name("@[stdlib/http]").exec() |
||||||
|
} |
||||||
Loading…
Reference in new issue