mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user