mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
203 lines
7.4 KiB
Lua
203 lines
7.4 KiB
Lua
-- Background HTTP for sandboxed mods, behind the "network" permission.
|
|
--
|
|
-- The sandbox blocks love.thread because newThread boots a Lua state with a
|
|
-- full standard library that none of the sandbox's rules reach -- one call and
|
|
-- a mod has io back. That is correct, but it left mods with no way to do
|
|
-- anything off the main thread at all: the only reachable transports
|
|
-- (socket, http) block, so a mod that wanted to fetch something had to hang
|
|
-- the game to do it.
|
|
--
|
|
-- This is the narrow replacement. src/net/Fetch.lua already runs a pool of
|
|
-- engine-owned worker threads, and those workers run OUR code, not the mod's,
|
|
-- so handing a mod a job in that pool grants no new reach. A mod submits a
|
|
-- URL and polls for the body; it never gets a thread, a path, or a raw handle
|
|
-- into the shared job table.
|
|
--
|
|
-- WHAT THIS FILE HAS TO GET RIGHT, because Fetch itself is shared with the
|
|
-- launcher:
|
|
-- * Handles are opaque tables owned per mod. Fetch keys jobs by integer,
|
|
-- and the launcher's own ROM download and index fetches live in the same
|
|
-- table; an integer handed to a mod would let it poll (or cancel) work
|
|
-- that is not its own. A forged table simply misses the lookup.
|
|
-- * Only http and https. The transport is curl, which also speaks file://,
|
|
-- scp:// and ftp://; without this check mod.fetch would be a filesystem
|
|
-- read and the sandbox would be back to square one.
|
|
-- * A per-mod ceiling on jobs in flight, so one mod cannot fill the shared
|
|
-- three-worker pool and starve the launcher's own fetches.
|
|
|
|
local Net = {}
|
|
|
|
-- Per mod, not global: the pool is shared with the launcher and a mod should
|
|
-- never be able to monopolise it.
|
|
Net.MAX_INFLIGHT = 4
|
|
-- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely.
|
|
Net.MAX_SECONDS = 30
|
|
-- A log body ceiling. Debug logs are kilobytes, and a server operator has no
|
|
-- reason to accept a mod uploading arbitrary megabytes to its endpoint.
|
|
Net.MAX_BODY = 65536
|
|
|
|
local function fetch()
|
|
return require("src.net.Fetch")
|
|
end
|
|
|
|
-- http/https only, and a host must actually be present -- "http://" alone
|
|
-- reaches curl as a malformed URL rather than being refused here.
|
|
function Net.urlDenial(url)
|
|
if type(url) ~= "string" or url == "" then return "url must be a string" end
|
|
local scheme, rest = url:match("^(%a[%w+.-]*)://(.*)$")
|
|
if not scheme then return "url must start with http:// or https://" end
|
|
scheme = scheme:lower()
|
|
if scheme ~= "http" and scheme ~= "https" then
|
|
return ("%s:// is not allowed; mod.fetch speaks http and https only")
|
|
:format(scheme)
|
|
end
|
|
if rest == "" or rest:match("^/") then return "url has no host" end
|
|
return nil
|
|
end
|
|
|
|
local function bucket(loader, modId)
|
|
loader.netJobs = loader.netJobs or {}
|
|
local b = loader.netJobs[modId]
|
|
if not b then b = {}; loader.netJobs[modId] = b end
|
|
return b
|
|
end
|
|
|
|
local function inflight(b)
|
|
local n = 0
|
|
for _, id in pairs(b) do
|
|
if fetch().isPending(id) then n = n + 1 end
|
|
end
|
|
return n
|
|
end
|
|
|
|
function Net.available()
|
|
local ok, F = pcall(fetch)
|
|
if not ok then return false end
|
|
local okAvail, avail = pcall(F.available)
|
|
return okAvail and avail and true or false
|
|
end
|
|
|
|
-- Returns an opaque handle, or nil plus a reason.
|
|
function Net.get(loader, modId, url, opts)
|
|
local denial = Net.urlDenial(url)
|
|
if denial then return nil, denial end
|
|
opts = type(opts) == "table" and opts or {}
|
|
local b = bucket(loader, modId)
|
|
if inflight(b) >= Net.MAX_INFLIGHT then
|
|
return nil, ("too many requests in flight (limit %d); poll and release "
|
|
.. "the ones you have"):format(Net.MAX_INFLIGHT)
|
|
end
|
|
local maxSeconds = tonumber(opts.maxSeconds) or Net.MAX_SECONDS
|
|
if maxSeconds > Net.MAX_SECONDS then maxSeconds = Net.MAX_SECONDS end
|
|
if maxSeconds < 1 then maxSeconds = 1 end
|
|
-- The mod is named in the agent string so a server operator can see which
|
|
-- mod is calling them, and a mod cannot pretend to be the launcher.
|
|
local id = fetch().get(url, {
|
|
userAgent = "gen1recomp-mod/" .. tostring(modId),
|
|
accept = type(opts.accept) == "string" and opts.accept or nil,
|
|
maxSeconds = maxSeconds,
|
|
})
|
|
local handle = {}
|
|
b[handle] = id
|
|
return handle
|
|
end
|
|
|
|
-- The closed list of postLog format switches. Anything outside it is a
|
|
-- caller bug, rejected before a job is submitted, so the surface stays
|
|
-- exactly two shapes on the wire.
|
|
local POST_FORMATS = { text = true, json = true }
|
|
|
|
-- A one-way log POST to the mod's manifest-declared log_url (https only,
|
|
-- validated in Manifest.lua). Same shape as get(): opaque handle, per-mod
|
|
-- in-flight ceiling, user agent naming the mod. The response body is never
|
|
-- returned -- a postLog is fire-and-forget reporting, and the engine has no
|
|
-- reason to hand a mod a server's reply.
|
|
function Net.postLog(loader, modId, logUrl, body, opts)
|
|
if type(body) ~= "string" or body == "" then
|
|
return nil, "log body must be a non-empty string"
|
|
end
|
|
if #body > Net.MAX_BODY then
|
|
return nil, ("log body too large (%d bytes, limit %d)"):format(#body, Net.MAX_BODY)
|
|
end
|
|
opts = type(opts) == "table" and opts or {}
|
|
for key in pairs(opts) do
|
|
if key ~= "format" then
|
|
return nil, ("unknown log option %q (format is the only switch)"):format(tostring(key))
|
|
end
|
|
end
|
|
local format = opts.format or "text"
|
|
if not POST_FORMATS[format] then
|
|
return nil, ("unknown log format %q (text and json only)"):format(tostring(format))
|
|
end
|
|
local denial = Net.urlDenial(logUrl)
|
|
if denial then return nil, denial end
|
|
local b = bucket(loader, modId)
|
|
if inflight(b) >= Net.MAX_INFLIGHT then
|
|
return nil, ("too many requests in flight (limit %d); poll and release "
|
|
.. "the ones you have"):format(Net.MAX_INFLIGHT)
|
|
end
|
|
local payload = body
|
|
local contentType = "text/plain"
|
|
if format == "json" then
|
|
local Json = require("src.link.Json")
|
|
payload = Json.encode({
|
|
ts = os.time(),
|
|
mod = modId,
|
|
format = "json",
|
|
body = body,
|
|
})
|
|
contentType = "application/json"
|
|
end
|
|
local id = fetch().post(logUrl, payload, {
|
|
userAgent = "gen1recomp-mod/" .. tostring(modId),
|
|
contentType = contentType,
|
|
maxSeconds = Net.MAX_SECONDS,
|
|
})
|
|
local handle = {}
|
|
b[handle] = id
|
|
return handle
|
|
end
|
|
|
|
-- A copy of the job's state, never the engine's own table. An unknown or
|
|
-- forged handle reads as an error rather than nil, so a mod that lost track of
|
|
-- one cannot spin waiting on it forever.
|
|
function Net.poll(loader, modId, handle)
|
|
local id = bucket(loader, modId)[handle]
|
|
if not id then return { status = "error", err = "unknown request" } end
|
|
local st = fetch().poll(id)
|
|
return { status = st.status, body = st.body, err = st.err,
|
|
progress = st.progress }
|
|
end
|
|
|
|
function Net.release(loader, modId, handle)
|
|
local b = bucket(loader, modId)
|
|
local id = b[handle]
|
|
if not id then return false end
|
|
fetch().release(id)
|
|
b[handle] = nil
|
|
return true
|
|
end
|
|
|
|
function Net.cancel(loader, modId, handle)
|
|
local id = bucket(loader, modId)[handle]
|
|
if not id then return false end
|
|
fetch().cancel(id)
|
|
return true
|
|
end
|
|
|
|
-- Drop everything this mod still holds. Called when a mod unloads, so a
|
|
-- disabled mod cannot leave jobs accumulating in the shared table.
|
|
function Net.releaseAll(loader, modId)
|
|
local b = loader.netJobs and loader.netJobs[modId]
|
|
if not b then return end
|
|
local F = fetch()
|
|
for handle, id in pairs(b) do
|
|
pcall(F.cancel, id)
|
|
pcall(F.release, id)
|
|
b[handle] = nil
|
|
end
|
|
loader.netJobs[modId] = nil
|
|
end
|
|
|
|
return Net
|