mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 03:35:56 +02:00
feat(mods): add mod.postLog one-way log reporting to a manifest-declared URL
This commit is contained in:
@@ -417,4 +417,56 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
|
||||
return body
|
||||
end
|
||||
|
||||
-- POST returning success/failure. Strictly one-way: the response body is
|
||||
-- discarded, only the HTTP status class is surfaced (postLog callers never
|
||||
-- trust the reply). curl --data-binary reads the payload from a pipe, so a
|
||||
-- large body never lands in the command line; the Android bridge has no POST
|
||||
-- transport, and httpPost reports that instead of half-working through
|
||||
-- httpDownload (a GET round-trip to a POST endpoint would be a lie).
|
||||
function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||
if type(body) ~= "string" then return nil, "missing body" end
|
||||
userAgent = userAgent or "gen1recomp"
|
||||
if HostShell.haveCurl() then
|
||||
-- --data-binary @- keeps the payload out of argv (command-line length
|
||||
-- limits on Windows) and preserves every byte including trailing
|
||||
-- newlines. No -f, matching httpGet: the response body is discarded
|
||||
-- anyway, and curl's stderr carries the real diagnosis on failure.
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40)
|
||||
.. "-X POST "
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if contentType then
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Content-Type: " .. contentType) .. " "
|
||||
end
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Content-Length: " .. tostring(#body)) .. " "
|
||||
.. "--data-binary @- "
|
||||
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
local pipe = HostShell.popen(cmd, "rw")
|
||||
if not pipe then return nil, "could not run curl" end
|
||||
local writeOk, werr = pcall(pipe.write, pipe, body)
|
||||
if not writeOk then
|
||||
HostShell.pclose(pipe)
|
||||
return nil, "could not write body: " .. tostring(werr)
|
||||
end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
HostShell.pclose(pipe)
|
||||
if not readOk then
|
||||
return nil, fetchError(url, nil, tostring(out))
|
||||
end
|
||||
local _, status, noise = splitCurlOutput(out)
|
||||
if not status then return nil, fetchError(url, nil, noise) end
|
||||
if status < 200 or status >= 300 then
|
||||
return nil, fetchError(url, status, "log post rejected")
|
||||
end
|
||||
return true
|
||||
end
|
||||
if not haveBridge() then
|
||||
return nil, "no network transport on this platform"
|
||||
end
|
||||
return nil, "no POST transport on this platform"
|
||||
end
|
||||
|
||||
return HostShell
|
||||
|
||||
@@ -1094,6 +1094,23 @@ function Loader:_api(mod)
|
||||
return { available = function() return false end,
|
||||
get = refuse, poll = refuse, release = refuse, cancel = refuse }
|
||||
end)(),
|
||||
-- One-way crash-log reporting to the https URL the manifest declares in
|
||||
-- log_url. The destination is reviewed at load, not chosen per call, so
|
||||
-- a mod cannot aim this at arbitrary hosts; the response body is never
|
||||
-- returned, and the worker pool bounds the transfer. Same handle/poll/
|
||||
-- release shape as mod.fetch, so mod.job's sibling patterns carry over.
|
||||
postLog = (function()
|
||||
if mod.manifest.permissionSet.network and mod.manifest.log_url then
|
||||
return function(_, body, opts)
|
||||
return Net.postLog(loader, modId, mod.manifest.log_url, body, opts)
|
||||
end
|
||||
end
|
||||
local function refuse()
|
||||
error(('[%s] mod.postLog needs the "network" permission and a '
|
||||
.. "log_url in manifest.json"):format(modId), 2)
|
||||
end
|
||||
return refuse
|
||||
end)(),
|
||||
-- Background compute, behind the "background" permission. The worker
|
||||
-- rebuilds this mod's sandbox before loading the script, so a job is the
|
||||
-- one thing love.thread is not: off the main thread without a Lua state
|
||||
|
||||
@@ -308,6 +308,24 @@ function Manifest.validate(raw, path)
|
||||
|
||||
local github = Manifest.parseGithub(raw.github)
|
||||
|
||||
-- log_url: the mod's one-way crash-log reporting destination. https-only,
|
||||
-- declared in the manifest so the engine reviews the target at load instead
|
||||
-- of trusting per-call URLs from gameplay code, and gated on the `network`
|
||||
-- permission the mod must also declare. api 1 mods never carry it: it is a
|
||||
-- load violation, not a warning, because a postLog-capable mod that does not
|
||||
-- opt in to networking is a bug in the manifest itself.
|
||||
local logUrl = nil
|
||||
if raw.log_url ~= nil then
|
||||
if strict and not permissionSet.network then
|
||||
violation(strict, raw.id, "log_url requires the network permission")
|
||||
elseif strict and (type(raw.log_url) ~= "string"
|
||||
or not raw.log_url:match("^https://")) then
|
||||
violation(strict, raw.id, "log_url must be an https:// URL")
|
||||
elseif strict then
|
||||
logUrl = raw.log_url
|
||||
end
|
||||
end
|
||||
|
||||
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
|
||||
"experimental must be a boolean")
|
||||
local experimental = raw.experimental == true
|
||||
@@ -414,6 +432,7 @@ function Manifest.validate(raw, path)
|
||||
affects_link = affectsLink,
|
||||
permissions = permissions,
|
||||
permissionSet = permissionSet,
|
||||
log_url = logUrl,
|
||||
options_schema = optionalFile(raw.options_schema, "options_schema"),
|
||||
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
|
||||
required_imports = requiredImports,
|
||||
|
||||
@@ -32,6 +32,9 @@ local Net = {}
|
||||
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")
|
||||
@@ -99,6 +102,62 @@ function Net.get(loader, modId, url, opts)
|
||||
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.
|
||||
|
||||
@@ -132,6 +132,17 @@ function Fetch.get(url, opts)
|
||||
accept = opts.accept, maxSeconds = opts.maxSeconds })
|
||||
end
|
||||
|
||||
-- POST a body to a URL, one-way. The result carries no body: postLog
|
||||
-- reporting never trusts a server's reply, so the worker surfaces only
|
||||
-- ok/error and the transport's complaint.
|
||||
-- opts: { userAgent, contentType, maxSeconds }
|
||||
function Fetch.post(url, body, opts)
|
||||
opts = opts or {}
|
||||
return submit({ kind = "post", url = url, body = body,
|
||||
userAgent = opts.userAgent or "gen1recomp",
|
||||
contentType = opts.contentType, maxSeconds = opts.maxSeconds })
|
||||
end
|
||||
|
||||
-- Download a URL to `saveRel`, a path relative to the LOVE save directory.
|
||||
-- Progress is reported as a 0..1 fraction when `size` is known.
|
||||
function Fetch.download(url, saveRel, opts)
|
||||
|
||||
@@ -99,6 +99,20 @@ local function doDownload(job)
|
||||
post({ id = job.id, ok = true, path = rel, done = true })
|
||||
end
|
||||
|
||||
local function doPost(job)
|
||||
if not HostShell then
|
||||
post({ id = job.id, ok = false, err = "no transport" })
|
||||
return
|
||||
end
|
||||
local ok, err = HostShell.httpPost(job.url, job.body, job.contentType,
|
||||
job.userAgent, tonumber(job.maxSeconds) or GET_MAX_SECONDS)
|
||||
if not ok then
|
||||
post({ id = job.id, ok = false, err = err or "post failed" })
|
||||
return
|
||||
end
|
||||
post({ id = job.id, ok = true, done = true })
|
||||
end
|
||||
|
||||
while true do
|
||||
local job = cmdCh:demand()
|
||||
-- The flag is checked before the job's KIND, so a worker woken by a
|
||||
@@ -114,6 +128,9 @@ while true do
|
||||
elseif job.kind == "get" then
|
||||
local ok, err = pcall(doGet, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
elseif job.kind == "post" then
|
||||
local ok, err = pcall(doPost, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
elseif job.kind == "download" then
|
||||
local ok, err = pcall(doDownload, job)
|
||||
if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end
|
||||
|
||||
Reference in New Issue
Block a user