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 TransferProgress = require("bookorbit_transfer_progress")
local MAX_BODY_BYTES = 900 * 1024 -- stays under the server's 1 MiB body limit 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 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. -- validation; every empty table in our payloads is semantically an array.
local ENCODE_OPTIONS = { empty_table_as_array = true } 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 -- 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. -- Lua and would leak into sidecars and truthiness checks; treat it as absent.
local function scrubNulls(value) local function scrubNulls(value)
@@ -482,21 +520,38 @@ end
-- BookOrbit plugin endpoints (camelCase wire format) -- BookOrbit plugin endpoints (camelCase wire format)
function BookOrbitApi:matchCheck(hashes, candidates) function BookOrbitApi:matchCheck(hashes, candidates)
local payload = { hashes = hashes } local payload = { hashes = {} }
if candidates then if candidates then
payload.books = {} 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 {} local cand = candidates[hash] or {}
table.insert(payload.books, { table.insert(payload.books, {
hash = hash, hash = hash,
title = cand.title, title = boundedUtf8(cand.title, MATCH_TITLE_MAX_BYTES),
authors = cand.authors, authors = boundedUtf8(cand.authors, MATCH_AUTHORS_MAX_BYTES),
lastOpen = cand.last_open, lastOpen = nonNegativeInteger(cand.last_open),
source = cand.source, source = validMatchSource(cand.source),
metadataAmbiguous = cand.metadata_ambiguous, metadataAmbiguous = optionalBoolean(cand.metadata_ambiguous),
}) })
end end
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)) return self:request("POST", "/koreader/plugin/match-check", self:withDevice(payload))
end end
@@ -256,8 +256,13 @@ local Menu_onFirstPage = Menu.onFirstPage
local Menu_onLastPage = Menu.onLastPage local Menu_onLastPage = Menu.onLastPage
local Menu_onClose = Menu.onClose local Menu_onClose = Menu.onClose
function BookOrbitCatalog:setApi(api)
self.api = api
self.client = BookOrbitApi.new(api)
end
function BookOrbitCatalog:init() function BookOrbitCatalog:init()
self.client = BookOrbitApi.new(self.api) self:setApi(self.api)
self.stack = {} self.stack = {}
self.settings = self.settings or {} self.settings = self.settings or {}
CatalogWidgets.setAssetIconFiles({ CatalogWidgets.setAssetIconFiles({
@@ -702,6 +702,17 @@ function MainMenu:showDashboardMenu(catalog)
UIManager:show(menu_container) UIManager:show(menu_container)
end 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() function MainMenu:setServerAddress()
local dialog local dialog
dialog = InputDialog:new{ dialog = InputDialog:new{
@@ -722,7 +733,7 @@ function MainMenu:setServerAddress()
is_enter_default = true, is_enter_default = true,
callback = function() callback = function()
local normalized = BookOrbitApi.normalizeServerUrl(dialog:getInputText()) local normalized = BookOrbitApi.normalizeServerUrl(dialog:getInputText())
self.settings.server_url = normalized self:applyServerAddress(normalized)
UIManager:close(dialog) UIManager:close(dialog)
if normalized then if normalized then
Notification:notify(T(_("BookOrbit server set to %1"), normalized)) Notification:notify(T(_("BookOrbit server set to %1"), normalized))
@@ -801,6 +812,7 @@ function MainMenu:doLogin(username, password, menu)
if body then if body then
self.settings.username = username self.settings.username = username
self.settings.userkey = userkey self.settings.userkey = userkey
self:refreshActiveCatalogApi(true)
if menu then menu:updateItems() end if menu then menu:updateItems() end
UIManager:show(InfoMessage:new{ text = _("Logged in to BookOrbit.") }) UIManager:show(InfoMessage:new{ text = _("Logged in to BookOrbit.") })
UIManager:scheduleIn(1, function() UIManager:scheduleIn(1, function()
@@ -821,6 +833,7 @@ end
function MainMenu:logout(menu) function MainMenu:logout(menu)
self.settings.userkey = nil self.settings.userkey = nil
self:refreshActiveCatalogApi(false)
if menu then menu:updateItems() end if menu then menu:updateItems() end
end end
@@ -199,7 +199,7 @@ local function finish(ctx, err)
end end
if ctx.plugin and ctx.plugin.recordSyncError then 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) ctx.plugin:recordSyncError("sweep", err)
elseif not err and ctx.had_errors then elseif not err and ctx.had_errors then
ctx.plugin:recordSyncError("sweep", "partial_failure") ctx.plugin:recordSyncError("sweep", "partial_failure")
@@ -243,6 +243,17 @@ local function finish(ctx, err)
return return
end 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 if ctx.interactive then
local text = T(_("BookOrbit sync done: %1 books matched, %2 reading events, %3 highlights."), 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) 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 aborted(ctx) then return end
if not body then if not body then
if isAuthError(err) then return finish(ctx, "auth") end if isAuthError(err) then return finish(ctx, "auth") end
if type(err) == "number" then return finish(ctx, "server") end
return finish(ctx, "network") return finish(ctx, "network")
end end
@@ -59,6 +59,7 @@ local SYNC_STRATEGY = {
local LAST_ERROR_LABELS = { local LAST_ERROR_LABELS = {
auth = _("login failed"), auth = _("login failed"),
network = _("server not reachable"), network = _("server not reachable"),
server = _("server request failed"),
invalid_json = _("invalid server response"), invalid_json = _("invalid server response"),
unsupported_server = _("server update required"), unsupported_server = _("server update required"),
body_too_large = _("request too large"), body_too_large = _("request too large"),
@@ -6,12 +6,16 @@ local encoded_subprocess_result
package.loaded["logger"] = { package.loaded["logger"] = {
dbg = function() end, dbg = function() end,
warn = function() end,
} }
package.loaded["util"] = { package.loaded["util"] = {
trim = function(value) trim = function(value)
return tostring(value or ""):match("^%s*(.-)%s*$") return tostring(value or ""):match("^%s*(.-)%s*$")
end, end,
fixUtf8 = function(value)
return value
end,
urlEncode = function(value) urlEncode = function(value)
return tostring(value) return tostring(value)
end, end,
@@ -66,8 +70,10 @@ package.loaded["ltn12"] = {
} }
local last_request_url local last_request_url
local request_count = 0
package.loaded["socket.http"] = { package.loaded["socket.http"] = {
request = function(request) request = function(request)
request_count = request_count + 1
request_ran_in_subprocess = in_subprocess request_ran_in_subprocess = in_subprocess
last_request_url = request.url last_request_url = request.url
if mock_http_body then if mock_http_body then
@@ -78,6 +84,7 @@ package.loaded["socket.http"] = {
} }
local rapidjson_null = {} local rapidjson_null = {}
local last_encoded_value
package.loaded["rapidjson"] = { package.loaded["rapidjson"] = {
null = rapidjson_null, null = rapidjson_null,
encode = function(value) encode = function(value)
@@ -86,6 +93,7 @@ package.loaded["rapidjson"] = {
encoded_subprocess_result = value encoded_subprocess_result = value
return "__subprocess_result__" return "__subprocess_result__"
end end
last_encoded_value = value
return "{}" return "{}"
end, end,
decode = function(raw) decode = function(raw)
@@ -171,6 +179,62 @@ assertEqual(last_request_url,
"https://bookorbit.example.com/api/v1/koreader/plugin/catalog/dashboard/sections/up-next-in-series", "https://bookorbit.example.com/api/v1/koreader/plugin/catalog/dashboard/sections/up-next-in-series",
"the dashboard-section endpoint is addressed by source type") "the dashboard-section endpoint is addressed by source type")
local match_client = BookOrbitApi.new{
server_url = "https://bookorbit.example.com/api/v1",
username = "reader",
userkey = "secret",
device_id = "device-id",
device_model = "Test device",
plugin_version = "1.5.1",
}
local hash_a = string.rep("a", 32)
local hash_b = string.rep("B", 32)
local invalid_hash = "not-an-md5"
local oversized_title = string.rep("t", 501)
local oversized_authors = string.rep("a", 1001)
mock_http_body = "{\"ok\":true}"
mock_http_code = 200
body, err = match_client:matchCheck({ hash_a, invalid_hash, hash_b }, {
[hash_a] = {
title = oversized_title,
authors = oversized_authors,
last_open = -1,
source = "invalid",
metadata_ambiguous = "yes",
},
[hash_b] = {
title = 123,
authors = {},
last_open = "123",
source = "file",
metadata_ambiguous = false,
},
})
assertEqual(body.ok, true, "a match-check with one invalid row still reaches the server")
assertEqual(err, nil, "a sanitized match-check succeeds")
assertEqual(#last_encoded_value.hashes, 2, "an invalid hash is removed without dropping valid peers")
assertEqual(last_encoded_value.hashes[1], hash_a, "the first valid hash keeps its position")
assertEqual(last_encoded_value.hashes[2], hash_b, "the later valid hash closes the filtered gap")
assertEqual(#last_encoded_value.books, 2, "candidate metadata stays aligned with filtered hashes")
assertEqual(#last_encoded_value.books[1].title, 500, "an oversized title is bounded to the server contract")
assertEqual(#last_encoded_value.books[1].authors, 1000, "oversized authors are bounded to the server contract")
assertEqual(last_encoded_value.books[1].lastOpen, nil, "a negative timestamp is omitted")
assertEqual(last_encoded_value.books[1].source, nil, "an unknown source is omitted")
assertEqual(last_encoded_value.books[1].metadataAmbiguous, nil, "a non-boolean ambiguity flag is omitted")
assertEqual(last_encoded_value.books[2].title, nil, "non-string titles are omitted")
assertEqual(last_encoded_value.books[2].authors, nil, "non-string authors are omitted")
assertEqual(last_encoded_value.books[2].lastOpen, 123, "a numeric timestamp string is normalized")
assertEqual(last_encoded_value.books[2].source, "file", "a valid source is preserved")
assertEqual(last_encoded_value.books[2].metadataAmbiguous, false, "a boolean ambiguity flag is preserved")
local requests_before_empty_match = request_count
body, err = match_client:matchCheck({ invalid_hash }, { [invalid_hash] = { title = "Ignored" } })
assertEqual(type(body), "table", "an all-invalid batch resolves locally")
assertEqual(#body.matches, 0, "an all-invalid batch has no matches")
assertEqual(err, nil, "an all-invalid batch is not reported as a network error")
assertEqual(request_count, requests_before_empty_match, "an empty sanitized batch is not sent to the server")
local wrapped = true local wrapped = true
local subprocess_calls = 0 local subprocess_calls = 0
local subprocess_result_mode = "normal" local subprocess_result_mode = "normal"
@@ -0,0 +1,146 @@
package.loaded["gettext"] = function(text)
return text
end
package.loaded["ffi/util"] = {
template = function(text, value)
return text:gsub("%%1", tostring(value))
end,
}
local ignored_input = {}
package.loaded["device"] = {
model = "test-device",
setIgnoreInput = function(_, value)
table.insert(ignored_input, value)
end,
}
local shown = {}
local scheduled = {}
package.loaded["ui/uimanager"] = {
show = function(_, widget)
table.insert(shown, widget)
end,
scheduleIn = function(_, _, callback)
table.insert(scheduled, callback)
end,
}
package.loaded["ui/widget/infomessage"] = { new = function(_, opts) return opts end }
package.loaded["ui/widget/inputdialog"] = {}
package.loaded["ui/widget/multiinputdialog"] = {}
package.loaded["ui/network/manager"] = {}
package.loaded["ui/widget/notification"] = {}
package.loaded["ffi/sha2"] = { md5 = function(value) return "md5:" .. value end }
package.loaded["util"] = {
trim = function(value)
return tostring(value or ""):match("^%s*(.-)%s*$")
end,
}
local auth_result = { authorized = "OK" }
local created_clients = {}
package.loaded["bookorbit_api"] = {
new = function(opts)
table.insert(created_clients, opts)
return {
auth = function()
if auth_result then return auth_result end
return nil, 401
end,
}
end,
}
package.loaded["bookorbit_highlight_diagnostics"] = {}
package.loaded["bookorbit_sweep"] = {
syncStatus = function()
return { lastSweepAt = 0, matched = 0, unmatched = 0 }
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local MainMenu = require("bookorbit_main_menu")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local function newPlugin()
local applied_api = {}
local refreshes = 0
local plugin = {
SYNC_STRATEGY = { PROMPT = 1, SILENT = 2, DISABLE = 3 },
settings = {
server_url = "https://old.example/api/v1",
username = "old-user",
userkey = "old-key",
},
apiOpts = function(self, background_requests)
return {
server_url = self.settings.server_url,
username = self.settings.username,
userkey = self.settings.userkey,
background_requests = background_requests == true,
}
end,
catalog_browser = {
setApi = function(_, api)
table.insert(applied_api, api)
end,
refreshCurrent = function()
refreshes = refreshes + 1
end,
},
}
MainMenu.install(plugin)
return plugin, applied_api, function() return refreshes end
end
local plugin, applied_api, refreshCount = newPlugin()
local menu_updates = 0
local menu = { updateItems = function() menu_updates = menu_updates + 1 end }
plugin:doLogin("new-user", "new-password", menu)
assertEqual(plugin.settings.username, "new-user", "successful login stores the username")
assertEqual(plugin.settings.userkey, "md5:new-password", "successful login stores the derived key")
assertEqual(#applied_api, 1, "successful login rebinds the active catalog")
assertEqual(applied_api[1].username, "new-user", "catalog receives the new username")
assertEqual(applied_api[1].userkey, "md5:new-password", "catalog receives the new key")
assertEqual(applied_api[1].background_requests, true, "catalog keeps background requests enabled")
assertEqual(refreshCount(), 1, "successful login refreshes the visible catalog")
assertEqual(menu_updates, 1, "successful login refreshes the account menu")
assertEqual(ignored_input[1], true, "login suppresses input while authenticating")
assertEqual(ignored_input[2], false, "login restores input after authenticating")
plugin:logout(menu)
assertEqual(plugin.settings.userkey, nil, "logout clears the stored key")
assertEqual(#applied_api, 2, "logout rebinds the active catalog")
assertEqual(applied_api[2].userkey, nil, "logout invalidates the catalog client key")
assertEqual(refreshCount(), 1, "logout does not issue an authenticated refresh")
assertEqual(menu_updates, 2, "logout refreshes the account menu")
plugin:applyServerAddress("https://new.example/api/v1")
assertEqual(plugin.settings.server_url, "https://new.example/api/v1", "server change updates settings")
assertEqual(#applied_api, 3, "server change rebinds the active catalog")
assertEqual(applied_api[3].server_url, "https://new.example/api/v1", "catalog receives the new server")
assertEqual(refreshCount(), 1, "server change does not make an implicit request")
plugin.catalog_browser = nil
plugin:applyServerAddress("https://closed.example/api/v1")
assertEqual(plugin.settings.server_url, "https://closed.example/api/v1", "server change works without an open catalog")
assertEqual(#applied_api, 3, "closed catalog needs no client rebind")
plugin, applied_api, refreshCount = newPlugin()
auth_result = nil
plugin:doLogin("rejected-user", "wrong-password")
assertEqual(plugin.settings.username, "old-user", "failed login preserves the previous username")
assertEqual(plugin.settings.userkey, "old-key", "failed login preserves the previous key")
assertEqual(#applied_api, 0, "failed login does not replace the catalog client")
assertEqual(refreshCount(), 0, "failed login does not refresh the catalog")
assertEqual(shown[#shown].text, "Login failed. Create or check your KOReader credentials in BookOrbit web settings.",
"failed login shows the credential error")
print("bookorbit_catalog_auth_lifecycle_test: ok")
@@ -167,6 +167,26 @@ do
"a candidate-less matched book with no stamp is refreshed by the sweep") "a candidate-less matched book with no stamp is refreshed by the sweep")
end end
-- An HTTP response proves that the server was reachable. It must not be
-- collapsed into the transport-error path and shown as an offline failure.
do
local harness = SweepHarness.install{
books = { { md5 = "aaa", id = 1, title = "A", last_open = 100 } },
match_error = 400,
}
local Sweep = require("bookorbit_sweep")
local _, result = startSweep(harness, Sweep)
harness.scheduler:drain()
local finished, err = result()
assertEqual(finished, true, "a rejected sweep reports completion")
assertEqual(err, "server", "an HTTP rejection is classified as a server failure")
assertEqual(harness.shown.text, "BookOrbit sync: server request failed.",
"an HTTP rejection is not presented as an unreachable server")
assertEqual(harness.calls.sweep_complete, 0, "a rejected match phase is not recorded as a completed sweep")
end
-- A library recreation can replace a server file without an intervening -- A library recreation can replace a server file without an intervening
-- unmatched response. The rematch must reset the old target's cursor before -- unmatched response. The rematch must reset the old target's cursor before
-- the statistics queue is built, then retain the new server acknowledgment. -- the statistics queue is built, then retain the new server acknowledgment.
@@ -80,6 +80,7 @@ local function installClient(handle)
isConfigured = function() return true end, isConfigured = function() return true end,
matchCheck = function(_, hashes) matchCheck = function(_, hashes)
table.insert(handle.calls.match, hashes) table.insert(handle.calls.match, hashes)
if responses.match_error then return nil, responses.match_error end
local matches = {} local matches = {}
for _, hash in ipairs(hashes) do for _, hash in ipairs(hashes) do
if responses.unmatched[hash] then if responses.unmatched[hash] then
@@ -129,6 +130,7 @@ opts:
- library_version: token every server response carries - library_version: token every server response carries
- unmatched: set of hashes match-check refuses to match - unmatched: set of hashes match-check refuses to match
- matches: map of hashes to custom { bookId, bookFileId } match results - matches: map of hashes to custom { bookId, bookFileId } match results
- match_error: HTTP or transport error returned by match-check
- page_stats_response: function returning the server response for an upload - page_stats_response: function returning the server response for an upload
- sidecar: overrides for the BookOrbitSidecar stub - sidecar: overrides for the BookOrbitSidecar stub
]] ]]
@@ -151,6 +153,7 @@ function SweepHarness.install(opts)
library_version = opts.library_version, library_version = opts.library_version,
unmatched = opts.unmatched or {}, unmatched = opts.unmatched or {},
matches = opts.matches or {}, matches = opts.matches or {},
match_error = opts.match_error,
page_stats = opts.page_stats_response, page_stats = opts.page_stats_response,
}, },
calls = { calls = {
@@ -229,7 +232,7 @@ function SweepHarness.install(opts)
handle.calls.scheduled = handle.calls.scheduled + 1 handle.calls.scheduled = handle.calls.scheduled + 1
handle.scheduler:scheduleIn(delay, callback) handle.scheduler:scheduleIn(delay, callback)
end, end,
show = function() end, show = function(_, widget) handle.shown = widget end,
close = function() end, close = function() end,
setDirty = function() end, setDirty = function() end,
} }
@@ -342,8 +342,11 @@ describe('KOReader plugin update source wiring', () => {
const sweep = await readPluginFile('bookorbit_sweep.lua'); const sweep = await readPluginFile('bookorbit_sweep.lua');
const statsReader = await readPluginFile('bookorbit_stats_reader.lua'); const statsReader = await readPluginFile('bookorbit_stats_reader.lua');
expect(api).toContain('source = cand.source'); expect(api).toContain('title = boundedUtf8(cand.title, MATCH_TITLE_MAX_BYTES)');
expect(api).toContain('metadataAmbiguous = cand.metadata_ambiguous'); expect(api).toContain('authors = boundedUtf8(cand.authors, MATCH_AUTHORS_MAX_BYTES)');
expect(api).toContain('lastOpen = nonNegativeInteger(cand.last_open)');
expect(api).toContain('source = validMatchSource(cand.source)');
expect(api).toContain('metadataAmbiguous = optionalBoolean(cand.metadata_ambiguous)');
expect(statsReader).toContain('entry.metadata_ambiguous = (entry._variant_count or 0) > 1'); expect(statsReader).toContain('entry.metadata_ambiguous = (entry._variant_count or 0) > 1');
expect(sweep).toContain('source = "statistics"'); expect(sweep).toContain('source = "statistics"');
expect(sweep).toContain('metadata_ambiguous = entry.metadata_ambiguous'); expect(sweep).toContain('metadata_ambiguous = entry.metadata_ambiguous');