singe/assets/Master.singe

522 lines
17 KiB
Text

--[[
*
* Singe 3
* Copyright (C) 2006-2026 Scott Duensing <scott@kangaroopunch.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*
*
--]]
-- The master service, from the cabinet's side.
--
-- One place that knows the server address, the signed-in account and the player's name, so the menu
-- and every game agree about them. It lives in the data root rather than a game's own directory
-- because an account belongs to the machine: a game posting a score needs the same token the menu
-- signed in with.
--
-- Everything here is asynchronous -- see Singe/Net.singe for why -- so every call takes a function
-- and calls it back. masterPump() must run once a frame for any of it to make progress.
dofile("Singe/Net.singe")
local cjson = require("cjson")
MASTER_DEFAULT_URL = "https://master.singeengine.com"
MASTER = {
url = MASTER_DEFAULT_URL,
token = nil,
email = nil,
name = nil,
queue = {}, -- scores waiting for a connection
}
local settingsPath = nil
local flushing = false
local lastFlush = 0
local flushDue = false
local FLUSH_EVERY = 30000
-- master.dat holds a session token and an email address, so it is not kept as plain text. A cabinet
-- signs in unattended, so whatever reads the file back is on the machine too: this keeps the token
-- and the address from being read at a glance or copied out of a text editor, and an edited file
-- reads as no account at all, but it is no protection against someone with the file and this source.
-- The file is SEAL_MAGIC, a random nonce, a tag, and the JSON XORed with a keystream: SHA-256 of the
-- key, the nonce, and a block counter. The tag is SHA-256 of the key, the nonce, and the result.
local SEAL_MAGIC = "SMD1"
local SEAL_KEY = "Singe master.dat" -- Not a secret: see above
local SEAL_NONCE = 16 -- Bytes
local SEAL_TAG = 16 -- Bytes
local SEAL_BLOCK = 32 -- Bytes of keystream a SHA-256 gives
local function path()
if not settingsPath then
settingsPath = singeGetSystemInfo().dataRoot .. "master.dat"
end
return settingsPath
end
-- SHA-256 as bytes rather than hex.
local function sealDigest(data)
return (utilSha256(data):gsub("%x%x", function(pair) return string.char(tonumber(pair, 16)) end))
end
-- text XORed with the keystream for nonce: the same call seals and unseals.
local function sealStream(nonce, text)
local out = {}
for block = 0, (#text - 1) // SEAL_BLOCK do
local key = sealDigest(SEAL_KEY .. nonce .. string.pack(">I4", block))
local piece = text:sub(block * SEAL_BLOCK + 1, (block + 1) * SEAL_BLOCK)
local bytes = {}
for i = 1, #piece do
bytes[i] = string.char(piece:byte(i) ~ key:byte(i))
end
out[#out + 1] = table.concat(bytes)
end
return table.concat(out)
end
local function sealTag(nonce, sealed)
return sealDigest(SEAL_KEY .. "tag" .. nonce .. sealed):sub(1, SEAL_TAG)
end
-- text as master.dat keeps it, with a fresh nonce each time.
local function seal(text)
local nonce = sealDigest(tostring(os.time()) .. os.clock() .. tostring({}) .. math.random()):sub(1, SEAL_NONCE)
local sealed = sealStream(nonce, text)
return SEAL_MAGIC .. nonce .. sealTag(nonce, sealed) .. sealed
end
-- The text master.dat holds: unsealed, or as it is when it is still plain JSON (from before files
-- were sealed); nil when the tag does not match.
local function unseal(data)
local nonce = data:sub(#SEAL_MAGIC + 1, #SEAL_MAGIC + SEAL_NONCE)
local tag = data:sub(#SEAL_MAGIC + SEAL_NONCE + 1, #SEAL_MAGIC + SEAL_NONCE + SEAL_TAG)
local sealed = data:sub(#SEAL_MAGIC + SEAL_NONCE + SEAL_TAG + 1)
if data:sub(1, #SEAL_MAGIC) ~= SEAL_MAGIC then
return data
end
if (#nonce ~= SEAL_NONCE) or (sealTag(nonce, sealed) ~= tag) then
return nil
end
return sealStream(nonce, sealed)
end
function masterLoad()
local file = io.open(path(), "rb")
local data = nil
local text = nil
local ok = false
local stored = nil
if not file then
return
end
data = file:read("a")
file:close()
text = unseal(data)
if text == nil then
return
end
ok, stored = pcall(cjson.decode, text)
if ok and type(stored) == "table" then
MASTER.url = stored.url or MASTER_DEFAULT_URL
MASTER.token = stored.token
MASTER.email = stored.email
MASTER.name = stored.name
MASTER.queue = stored.queue or {}
-- A file from before sealing is sealed as soon as it is read.
if text == data then
masterSave()
end
end
end
function masterSave()
local file = io.open(path(), "wb")
if not file then
return false
end
file:write(seal(cjson.encode({ url = MASTER.url, token = MASTER.token, email = MASTER.email,
name = MASTER.name, queue = MASTER.queue })))
file:close()
return true
end
function masterSignedIn()
return MASTER.token ~= nil
end
-- Every call goes through here so the token, the content type and the JSON decoding are in one
-- place, and so a 401 always means the same thing: whatever we were holding is no longer a
-- session, and pretending otherwise only produces a second failure later.
local function request(method, endpoint, body, onDone, opts)
opts = opts or {}
local headers = { ["Content-Type"] = "application/json" }
if MASTER.token and not opts.anonymous then
headers["Authorization"] = "Bearer " .. MASTER.token
end
if opts.bodyFile then
headers["Content-Type"] = opts.contentType or "application/octet-stream"
end
netRequest({
url = MASTER.url .. endpoint, method = method, headers = headers,
body = body and cjson.encode(body) or nil, bodyFile = opts.bodyFile,
toFile = opts.toFile, onProgress = opts.onProgress,
}, function(ok, result)
if not ok then
return onDone(false, result.error)
end
local parsed = {}
if result.body and result.body ~= "" then
local decoded, value = pcall(cjson.decode, result.body)
if decoded and type(value) == "table" then
parsed = value
end
end
if result.status == 401 and not opts.anonymous then
MASTER.token = nil
masterSave()
return onDone(false, "signed out by the server", parsed, result)
end
if result.status >= 400 then
return onDone(false, parsed.message or ("the server said " .. result.status), parsed, result)
end
onDone(true, parsed, parsed, result)
end)
end
MASTER_REQUEST = request
function masterSignIn(email, password, onDone)
request("POST", "/v1/account/login", { email = email, password = password }, function(ok, reply)
if not ok then
return onDone(false, reply)
end
MASTER.token = reply.token
MASTER.email = reply.email
masterSave()
-- The display name is not part of signing in, so it is fetched once and kept: a game
-- about to post a score should not have to discover it has none mid-play.
masterWhoAmI(function() end)
onDone(true, reply)
end, { anonymous = true })
end
function masterRegister(email, password, onDone)
request("POST", "/v1/account/register", { email = email, password = password }, onDone, { anonymous = true })
end
function masterRecover(email, onDone)
request("POST", "/v1/account/recover", { email = email }, onDone, { anonymous = true })
end
function masterSignOut(onDone)
local had = MASTER.token
MASTER.token = nil
MASTER.email = nil
MASTER.name = nil
masterSave()
if had then
-- Told after the fact: the cabinet is signed out either way, and a server that cannot be
-- reached must not leave somebody looking signed in.
MASTER.token = had
request("POST", "/v1/account/logout", nil, function() end)
MASTER.token = nil
end
if onDone then onDone(true) end
end
function masterWhoAmI(onDone)
request("GET", "/v1/account", nil, function(ok, reply)
if ok then
MASTER.email = reply.email or MASTER.email
masterSave()
end
onDone(ok, reply)
end)
end
function masterSetName(name, onDone)
request("POST", "/v1/player/name", { name = name }, function(ok, reply)
if ok then
MASTER.name = reply.name
masterSave()
end
onDone(ok, reply)
end)
end
function masterCatalogue(onDone)
request("GET", "/v1/catalogue", nil, function(ok, reply)
if ok then
for _, game in ipairs(reply.games or {}) do
game.version = game.version and math.floor(game.version) or nil
game.installed = game.installed and math.floor(game.installed) or nil
game.size = game.size and math.floor(game.size) or nil
-- Every title the download holds, in its games.dat order.
for _, entry in ipairs(game.entries or {}) do
entry.entry = entry.entry and math.floor(entry.entry) or nil
entry.year = entry.year and math.floor(entry.year) or nil
entry.tier = entry.tier and math.floor(entry.tier) or nil
end
end
end
onDone(ok, ok and reply.games or reply)
end)
end
-- Download a game to a file and check it against the digest the catalogue published. A download
-- that arrives wrong is worse than one that fails: it installs, and then does not run.
function masterDownload(gameId, version, toFile, expectSha, onProgress, onDone)
-- JSON has one number type and Lua 5.4 has two, so a version that went out as 1 comes back as
-- 1.0 and would build a path ending "/1.0" that the service does not recognize.
local path = string.format("/v1/download/%s/%d", gameId, math.floor(version))
request("GET", path, nil, function(ok, reply, _, result)
if not ok then
return onDone(false, reply)
end
local file = io.open(toFile, "rb")
if not file then
return onDone(false, "the download did not land on disk")
end
local bytes = file:read("*a")
file:close()
if expectSha and utilSha256(bytes) ~= expectSha then
os.remove(toFile)
return onDone(false, "the download did not match its published checksum")
end
onDone(true, { bytes = #bytes, file = toFile })
end, { toFile = toFile, onProgress = onProgress })
end
-- What this machine holds, and what is newer. The list is the menu's own, read off every
-- games.dat it loaded (GAME_ID and VERSION), and the service keeps none of it: it answers which
-- of these have a newer version and which it has never heard of. No sign-in needed to ask.
function masterVersions(held, onDone)
local games = {}
for _, entry in ipairs(held) do
games[#games + 1] = { id = entry.id, version = math.floor(entry.version or 0) }
end
request("POST", "/v1/versions", { games = games }, function(ok, reply)
if ok then
for _, update in ipairs(reply.updates or {}) do
update.installed = update.installed and math.floor(update.installed) or 0
update.latest = update.latest and math.floor(update.latest) or nil
update.size = update.size and math.floor(update.size) or nil
end
end
onDone(ok, ok and (reply.updates or {}) or reply, ok and (reply.unknown or {}) or nil)
end)
end
-- Send a packed game up for review. The file goes as the body, from disk, so a big game is not
-- held in memory; onProgress(sent, total) says how it goes, and onDone(ok, reply) ends with the
-- version number the service gave it and the state it starts in, which is "pending".
function masterUpload(file, notes, onProgress, onDone)
local endpoint = "/v1/upload"
if notes and notes ~= "" then
endpoint = endpoint .. "?notes=" .. notes:gsub("[^%w%-%._~]", function(c)
return string.format("%%%02X", c:byte())
end)
end
request("POST", endpoint, nil, function(ok, reply)
if ok and reply.version then
reply.version = math.floor(reply.version)
end
onDone(ok, reply)
end, { bodyFile = file, onProgress = onProgress })
end
-- Is there a newer Singe for this machine? Public, no sign-in: the answer is the newest release
-- for the platform when it is newer than the version running, or newer = false.
function masterRelease(platform, running, onDone)
request("GET", "/v1/release/" .. platform .. "?version=" .. tostring(running), nil, function(ok, reply)
onDone(ok, reply)
end, { anonymous = true })
end
-- Fetch one release to a file, checked against the digest the service published, exactly as a
-- game is; a binary that arrives wrong must not be put beside the one that works.
function masterDownloadRelease(platform, version, toFile, expectSha, onProgress, onDone)
request("GET", "/v1/release/" .. platform .. "/" .. version, nil, function(ok, reply, _, result)
if not ok then
return onDone(false, reply)
end
local file = io.open(toFile, "rb")
if not file then
return onDone(false, "the download did not land on disk")
end
local bytes = file:read("*a")
file:close()
if expectSha and utilSha256(bytes) ~= expectSha then
os.remove(toFile)
return onDone(false, "the download did not match its published checksum")
end
onDone(true, { bytes = #bytes, file = toFile })
end, { toFile = toFile, onProgress = onProgress, anonymous = true })
end
-- This account's own games, every version with the review's word on it.
function masterMine(onDone)
request("GET", "/v1/catalogue/mine", nil, function(ok, reply)
onDone(ok, ok and (reply.games or {}) or reply)
end)
end
-- One board. lowest asks for a board that ranks the lowest score first; a board keeps the direction
-- its first score fixed, and the reply's order says which it is.
function masterBoard(gameId, board, onDone, lowest)
local query = {}
if board then
query[#query + 1] = "board=" .. board
end
if lowest then
query[#query + 1] = "order=lowest"
end
request("GET", "/v1/scores/" .. gameId .. (#query > 0 and ("?" .. table.concat(query, "&")) or ""), nil, function(ok, reply)
-- JSON has one number type and Lua 5.4 has two, so a score that went out as 4242 comes back
-- as 4242.0 and would be shown to a player with a decimal point on it.
if ok then
for _, row in ipairs(reply.top or {}) do
row.value = math.floor(row.value)
row.at = row.at and math.floor(row.at) or nil
end
if reply.standing then
reply.standing.value = math.floor(reply.standing.value)
reply.standing.rank = math.floor(reply.standing.rank)
reply.standing.players = math.floor(reply.standing.players)
end
end
onDone(ok, reply)
end)
end
-- Tell the service a play is starting. What comes back travels with the score, so the server can
-- say how long the play took by its own clock; a cabinet that is offline simply has no play id and
-- the score carries no time, which is honest rather than broken.
function masterPlayStart(gameId, onDone)
if not masterSignedIn() then
MASTER.play = nil
if onDone then onDone(false, "not signed in") end
return
end
request("POST", "/v1/play/start", { game = gameId }, function(ok, reply)
MASTER.play = ok and reply.play and math.floor(reply.play) or nil
if onDone then onDone(ok, reply) end
end)
end
-- A score is queued first and sent afterwards. A cabinet is often offline, and a game must never
-- wait on the wire to show its own board; the queue is written to disk so a power cut between the
-- play and the send does not lose it. lowest sends it to a board that ranks the lowest first; one
-- queued without it (as every score was before boards had a direction) goes the highest first.
function masterSubmitScore(gameId, value, board, meta, lowest)
MASTER.queue[#MASTER.queue + 1] = { game = gameId, value = math.floor(value),
board = board or "default", meta = meta,
play = MASTER.play, order = lowest and "lowest" or nil }
masterSave()
-- Send it on the next pump rather than waiting out the retry interval: a player who has just
-- finished wants to see themselves on the board, and the interval exists for retrying what
-- failed, not for delaying what has not been tried. This is a flag rather than a timestamp:
-- singeGetTicks() counts from engine start, so "lastFlush = 0" would mean "once the engine has
-- been up FLUSH_EVERY", which early in a run is never.
flushDue = true
end
function masterQueueLength()
return #MASTER.queue
end
-- Hand the queue to the server one at a time. A refusal that is the score's own fault (the game is
-- unknown, the value is nonsense, the board ranks the other way) drops it, because retrying it
-- forever would block everything behind it; anything else leaves it in place to try again.
local function flushQueue()
if flushing or #MASTER.queue == 0 or not MASTER.token then
return
end
flushing = true
local entry = MASTER.queue[1]
request("POST", "/v1/scores/" .. entry.game, { value = entry.value, board = entry.board, meta = entry.meta, play = entry.play, order = entry.order },
function(ok, reply, parsed, result)
local status = result and result.status or 0
if ok or (status >= 400 and status < 500 and status ~= 429) then
table.remove(MASTER.queue, 1)
masterSave()
-- A backlog should drain, not trickle out one per interval.
if #MASTER.queue > 0 then
flushDue = true
end
end
flushing = false
end)
end
function masterPump()
netPump()
if flushDue or (singeGetTicks() - lastFlush > FLUSH_EVERY) then
flushDue = false
lastFlush = singeGetTicks()
flushQueue()
end
end
masterLoad()