fix(reader): harden KOReader sync and credential refresh

Sanitize match-check batches before strict server validation and classify HTTP failures accurately. Rebind the active catalog client whenever authentication settings change so retries use current credentials.

Fixes #1150
This commit is contained in:
neon
2026-08-28 15:29:36 +00:00
parent 3b44fa5d7e
commit f39e0e8ab5
10 changed files with 335 additions and 13 deletions
@@ -18,6 +18,10 @@ local TransferPolicy = require("bookorbit_transfer_policy")
local TransferProgress = require("bookorbit_transfer_progress")
local MAX_BODY_BYTES = 900 * 1024 -- stays under the server's 1 MiB body limit
local MATCH_HASH_LENGTH = 32
local MATCH_TITLE_MAX_BYTES = 500
local MATCH_AUTHORS_MAX_BYTES = 1000
local MAX_SAFE_INTEGER = 9007199254740991
local MAX_DOWNLOAD_REDIRECTS = 5
@@ -25,6 +29,40 @@ local MAX_DOWNLOAD_REDIRECTS = 5
-- validation; every empty table in our payloads is semantically an array.
local ENCODE_OPTIONS = { empty_table_as_array = true }
local function validMatchHash(value)
return type(value) == "string"
and #value == MATCH_HASH_LENGTH
and value:match("^[0-9a-fA-F]+$") ~= nil
end
-- Byte limits are conservative against the server's character limits and
-- keep a worst-case 500-book payload below the HTTP body cap.
local function boundedUtf8(value, max_bytes)
if type(value) ~= "string" then return nil end
value = util.fixUtf8(value, "?")
if #value <= max_bytes then return value end
return util.fixUtf8(value:sub(1, max_bytes), "")
end
local function nonNegativeInteger(value)
value = tonumber(value)
if not value or value ~= value or value == math.huge
or value < 0 or value > MAX_SAFE_INTEGER or value ~= math.floor(value) then
return nil
end
return value
end
local function validMatchSource(value)
if value == "current_file" or value == "file" or value == "statistics" then
return value
end
end
local function optionalBoolean(value)
if type(value) == "boolean" then return value end
end
-- JSON null decodes to the rapidjson.null lightuserdata, which is truthy in
-- Lua and would leak into sidecars and truthiness checks; treat it as absent.
local function scrubNulls(value)
@@ -482,21 +520,38 @@ end
-- BookOrbit plugin endpoints (camelCase wire format)
function BookOrbitApi:matchCheck(hashes, candidates)
local payload = { hashes = hashes }
local payload = { hashes = {} }
if candidates then
payload.books = {}
for _, hash in ipairs(hashes) do
end
local skipped = 0
for _, hash in ipairs(hashes or {}) do
local valid_hash = validMatchHash(hash)
if valid_hash then
table.insert(payload.hashes, hash)
else
skipped = skipped + 1
end
if candidates and valid_hash then
local cand = candidates[hash] or {}
table.insert(payload.books, {
hash = hash,
title = cand.title,
authors = cand.authors,
lastOpen = cand.last_open,
source = cand.source,
metadataAmbiguous = cand.metadata_ambiguous,
title = boundedUtf8(cand.title, MATCH_TITLE_MAX_BYTES),
authors = boundedUtf8(cand.authors, MATCH_AUTHORS_MAX_BYTES),
lastOpen = nonNegativeInteger(cand.last_open),
source = validMatchSource(cand.source),
metadataAmbiguous = optionalBoolean(cand.metadata_ambiguous),
})
end
end
if skipped > 0 then
logger.warn("BookOrbit: match-check skipped invalid hashes:", skipped)
end
if #payload.hashes == 0 then
return { matches = {} }
end
return self:request("POST", "/koreader/plugin/match-check", self:withDevice(payload))
end
@@ -256,8 +256,13 @@ local Menu_onFirstPage = Menu.onFirstPage
local Menu_onLastPage = Menu.onLastPage
local Menu_onClose = Menu.onClose
function BookOrbitCatalog:setApi(api)
self.api = api
self.client = BookOrbitApi.new(api)
end
function BookOrbitCatalog:init()
self.client = BookOrbitApi.new(self.api)
self:setApi(self.api)
self.stack = {}
self.settings = self.settings or {}
CatalogWidgets.setAssetIconFiles({
@@ -702,6 +702,17 @@ function MainMenu:showDashboardMenu(catalog)
UIManager:show(menu_container)
end
function MainMenu:refreshActiveCatalogApi(refresh)
if not self.catalog_browser then return end
self.catalog_browser:setApi(self:apiOpts(true))
if refresh then self.catalog_browser:refreshCurrent() end
end
function MainMenu:applyServerAddress(server_url)
self.settings.server_url = server_url
self:refreshActiveCatalogApi(false)
end
function MainMenu:setServerAddress()
local dialog
dialog = InputDialog:new{
@@ -722,7 +733,7 @@ function MainMenu:setServerAddress()
is_enter_default = true,
callback = function()
local normalized = BookOrbitApi.normalizeServerUrl(dialog:getInputText())
self.settings.server_url = normalized
self:applyServerAddress(normalized)
UIManager:close(dialog)
if normalized then
Notification:notify(T(_("BookOrbit server set to %1"), normalized))
@@ -801,6 +812,7 @@ function MainMenu:doLogin(username, password, menu)
if body then
self.settings.username = username
self.settings.userkey = userkey
self:refreshActiveCatalogApi(true)
if menu then menu:updateItems() end
UIManager:show(InfoMessage:new{ text = _("Logged in to BookOrbit.") })
UIManager:scheduleIn(1, function()
@@ -821,6 +833,7 @@ end
function MainMenu:logout(menu)
self.settings.userkey = nil
self:refreshActiveCatalogApi(false)
if menu then menu:updateItems() end
end
@@ -199,7 +199,7 @@ local function finish(ctx, err)
end
if ctx.plugin and ctx.plugin.recordSyncError then
if err == "auth" or err == "network" then
if err == "auth" or err == "network" or err == "server" then
ctx.plugin:recordSyncError("sweep", err)
elseif not err and ctx.had_errors then
ctx.plugin:recordSyncError("sweep", "partial_failure")
@@ -243,6 +243,17 @@ local function finish(ctx, err)
return
end
if err == "server" then
if ctx.interactive then
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync: server request failed."), timeout = 4 })
end
logger.warn("BookOrbit: sweep aborted, server request failed")
if ctx.on_finish then
pcall(ctx.on_finish, err)
end
return
end
if ctx.interactive then
local text = T(_("BookOrbit sync done: %1 books matched, %2 reading events, %3 highlights."),
ctx.counts.books_matched, ctx.counts.page_stats, ctx.counts.annotations)
@@ -606,6 +617,7 @@ local function stepMatchNext(ctx)
if aborted(ctx) then return end
if not body then
if isAuthError(err) then return finish(ctx, "auth") end
if type(err) == "number" then return finish(ctx, "server") end
return finish(ctx, "network")
end
@@ -59,6 +59,7 @@ local SYNC_STRATEGY = {
local LAST_ERROR_LABELS = {
auth = _("login failed"),
network = _("server not reachable"),
server = _("server request failed"),
invalid_json = _("invalid server response"),
unsupported_server = _("server update required"),
body_too_large = _("request too large"),