KOReader's bundled LuaJSON (json.require) decodes JSON null into a
truthy sentinel function instead of nil. getMetadata responses for a
newly linked book with no server-side progress carry
"progress": null, so the `if not result.progress` guard passed,
indexing the sentinel crashed KOReader outright, and the book could
not be opened ("attempt to index local 'progress' (a function value)"
at the pull handler). "annotations": null would crash
applyServerAnnotations the same way.
Strip the sentinels recursively at the decode boundary in _request so
every call site sees plain nil and existing nil-checks behave as
written. Reproduced in the KOReader flatpak runtime:
json.decode('{"a": null}') yields type(data.a) == "function".
Found while testing a newly OPDS-downloaded book that had no server
progress yet; the crash took down the whole application, not just the
plugin.
161 lines
4.7 KiB
Lua
161 lines
4.7 KiB
Lua
local json = require("json")
|
|
local json_util = require("json.util")
|
|
|
|
-- Recursively replace LuaJSON's truthy null-sentinel functions with nil
|
|
-- (JSON null), so server payloads behave like normal Lua tables.
|
|
local function strip_json_nulls(t)
|
|
for k, v in pairs(t) do
|
|
if type(v) == "function" then
|
|
t[k] = nil
|
|
elseif type(v) == "table" then
|
|
strip_json_nulls(v)
|
|
end
|
|
end
|
|
return t
|
|
end
|
|
local logger = require("logger")
|
|
local ltn12 = require("ltn12")
|
|
local socketutil = require("socketutil")
|
|
|
|
local http = require("socket.http")
|
|
local ssl_ok, ssl_https = pcall(require, "ssl.https")
|
|
|
|
local PROGRESS_TIMEOUTS = { 2, 5 }
|
|
local AUTH_TIMEOUTS = { 5, 10 }
|
|
|
|
local BookhoardAPI = {
|
|
server_url = nil,
|
|
auth_token = nil,
|
|
}
|
|
|
|
function BookhoardAPI:new(o)
|
|
o = o or {}
|
|
setmetatable(o, self)
|
|
self.__index = self
|
|
return o
|
|
end
|
|
|
|
function BookhoardAPI:_buildHeaders()
|
|
local headers = {
|
|
["Content-Type"] = "application/json",
|
|
["Accept"] = "application/json",
|
|
}
|
|
if self.auth_token then
|
|
headers["Authorization"] = "Bearer " .. self.auth_token
|
|
end
|
|
return headers
|
|
end
|
|
|
|
function BookhoardAPI:_request(method, path, body, timeouts)
|
|
if not self.server_url then
|
|
return false, { error = "server URL not configured" }
|
|
end
|
|
|
|
local url = self.server_url .. path
|
|
local sink = {}
|
|
local headers = self:_buildHeaders()
|
|
local body_str = body and json.encode(body) or nil
|
|
|
|
if body_str then
|
|
headers["Content-Length"] = tostring(#body_str)
|
|
end
|
|
|
|
local request = {
|
|
url = url,
|
|
method = method,
|
|
headers = headers,
|
|
sink = ltn12.sink.table(sink),
|
|
}
|
|
|
|
if body_str then
|
|
request.source = ltn12.source.string(body_str)
|
|
end
|
|
|
|
socketutil:set_timeout(timeouts[1], timeouts[2])
|
|
|
|
local ok, err, code
|
|
if url:match("^https://") then
|
|
if ssl_ok then
|
|
ok, err, code = pcall(ssl_https.request, request)
|
|
else
|
|
socketutil:reset_timeout()
|
|
return false, { error = "HTTPS not supported on this device" }
|
|
end
|
|
else
|
|
ok, err, code = pcall(http.request, request)
|
|
end
|
|
|
|
socketutil:reset_timeout()
|
|
|
|
if not ok then
|
|
logger.warn("BookhoardAPI: request failed:", err)
|
|
return false, { error = tostring(err) }
|
|
end
|
|
|
|
local response_body = table.concat(sink)
|
|
local status = tonumber(code)
|
|
|
|
if status and status >= 200 and status < 300 then
|
|
if response_body and #response_body > 0 then
|
|
local decode_ok, data = pcall(json.decode, response_body)
|
|
if decode_ok and type(data) == "table" then
|
|
-- LuaJSON decodes JSON null into a truthy sentinel FUNCTION,
|
|
-- which defeats every `if not x.field` nil-check downstream
|
|
-- (crash: "attempt to index local 'progress' (a function
|
|
-- value)"). Normalize sentinels to nil at the boundary.
|
|
strip_json_nulls(data)
|
|
return true, data
|
|
end
|
|
return true, response_body
|
|
end
|
|
return true, nil
|
|
end
|
|
|
|
local error_msg = "HTTP " .. tostring(code)
|
|
if response_body and #response_body > 0 then
|
|
local decode_ok, data = pcall(json.decode, response_body)
|
|
if decode_ok and data and data.error then
|
|
error_msg = data.error
|
|
end
|
|
end
|
|
logger.warn("BookhoardAPI:", method, path, "→", status, error_msg)
|
|
return false, { status = status, error = error_msg }
|
|
end
|
|
|
|
function BookhoardAPI:registerDevice(device_name, device_identifier)
|
|
return self:_request("POST", "/api/devices/register", {
|
|
device_name = device_name,
|
|
device_type = "koreader",
|
|
device_identifier = device_identifier,
|
|
}, AUTH_TIMEOUTS)
|
|
end
|
|
|
|
function BookhoardAPI:checkRegistrationStatus(registration_id)
|
|
return self:_request("POST", "/api/devices/register/status", {
|
|
registration_id = registration_id,
|
|
}, AUTH_TIMEOUTS)
|
|
end
|
|
|
|
function BookhoardAPI:syncProgress(book_data, sync_mode)
|
|
local books = json_util.InitArray({ book_data })
|
|
return self:_request("POST", "/api/sync/koreader/progress", {
|
|
books = books,
|
|
sync_mode = sync_mode or "immediate",
|
|
}, PROGRESS_TIMEOUTS)
|
|
end
|
|
|
|
-- Read-only SHA-256 → book UUID lookup. Used to link a freshly downloaded
|
|
-- book WITHOUT pushing progress (a push would transmit the device's
|
|
-- first-page position and clobber/conflict with real server progress).
|
|
function BookhoardAPI:resolveBook(sha256)
|
|
return self:_request("GET", "/api/sync/koreader/resolve?sha256=" .. sha256, nil, PROGRESS_TIMEOUTS)
|
|
end
|
|
|
|
function BookhoardAPI:getMetadata(uuid)
|
|
return self:_request("GET", "/api/sync/koreader/metadata/" .. uuid, nil, PROGRESS_TIMEOUTS)
|
|
end
|
|
|
|
|
|
|
|
return BookhoardAPI
|