mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
ec84703cef
* feat(koreader): add device file naming and improve plugin downloads (BO-658) * fix(koreader): secure download paths and test collisions * fix(koreader): preserve bulk collision identities * fix(koreader): improve device file naming validation and path generation * fix(koreader): remove duplicate settings lookup and audit file naming changes * fix(koreader): resolve remaining CodeRabbit review issues * fix(koreader): validate book detail query and finalize file naming release validate book detail deviceId via DTO and ValidationPipe localize new KOReader file naming settings (de, nl, sl) bump KOReader plugin version to 1.3.0 * chore: rerun CI * chore: normalized Closes #658 to not have embedded URL to issue. This is failing PR Conventions test * test(koreader): align source and device removal assertions * test(koreader): cover file naming settings flows * test(koreader): cover file naming sync requests * test(koreader): close remaining patch coverage gaps * test(koreader): cover file naming server paths * docs: add fixed2 modifier to file naming reference * feat(koreader): support custom file naming patterns for KOReader sync Add support for configurable KOReader file naming patterns, allowing users to define dynamic path templates for sync targets. Include UI draft settings, preview updates, and server path resolution. Closes #658 --------- Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com> Co-authored-by: jadehawk <jadehawk@hotmail.com>
361 lines
12 KiB
Lua
361 lines
12 KiB
Lua
--[[--
|
|
HTTP client for the BookOrbit server.
|
|
|
|
Speaks the kosync-compatible progress endpoints plus the BookOrbit plugin
|
|
endpoints. Sync clients run blocking requests in a subprocess when called from
|
|
a Trapper coroutine, keeping KOReader's UI loop responsive.
|
|
]]
|
|
|
|
local http = require("socket.http")
|
|
local ltn12 = require("ltn12")
|
|
local rapidjson = require("rapidjson")
|
|
local socket = require("socket")
|
|
local socketutil = require("socketutil")
|
|
local logger = require("logger")
|
|
local util = require("util")
|
|
|
|
local MAX_BODY_BYTES = 900 * 1024 -- stays under the server's 1 MiB body limit
|
|
|
|
-- Plain empty Lua tables would encode as {} and fail the server's array
|
|
-- validation; every empty table in our payloads is semantically an array.
|
|
local ENCODE_OPTIONS = { empty_table_as_array = true }
|
|
|
|
-- 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)
|
|
if value == rapidjson.null then return nil end
|
|
if type(value) == "table" then
|
|
for key, item in pairs(value) do
|
|
value[key] = scrubNulls(item)
|
|
end
|
|
end
|
|
return value
|
|
end
|
|
|
|
local function decodeResponse(parts)
|
|
local raw = table.concat(parts or {})
|
|
if raw == "" then
|
|
return {}
|
|
end
|
|
local ok, decoded, decode_err = pcall(rapidjson.decode, raw)
|
|
if not ok or decoded == nil then
|
|
logger.dbg("BookOrbit: invalid JSON response:", ok and decode_err or decoded)
|
|
return nil, "invalid_json"
|
|
end
|
|
return scrubNulls(decoded) or {}
|
|
end
|
|
|
|
local BookOrbitApi = {}
|
|
BookOrbitApi.__index = BookOrbitApi
|
|
BookOrbitApi.decodeResponse = decodeResponse
|
|
|
|
-- Normalizes a user-entered server address to the API base, e.g.
|
|
-- "https://books.example.com/" -> "https://books.example.com/api/v1".
|
|
function BookOrbitApi.normalizeServerUrl(input)
|
|
if not input then return nil end
|
|
local url = util.trim(input)
|
|
if url == "" then return nil end
|
|
url = url:gsub("/+$", "")
|
|
url = url:gsub("/api/v1/koreader$", "/api/v1")
|
|
if not url:match("/api/v1$") then
|
|
url = url .. "/api/v1"
|
|
end
|
|
return url
|
|
end
|
|
|
|
function BookOrbitApi.new(opts)
|
|
return setmetatable({
|
|
server_url = opts.server_url,
|
|
username = opts.username,
|
|
userkey = opts.userkey,
|
|
device_id = opts.device_id,
|
|
device_model = opts.device_model,
|
|
plugin_version = opts.plugin_version,
|
|
background_requests = opts.background_requests == true,
|
|
}, BookOrbitApi)
|
|
end
|
|
|
|
function BookOrbitApi:isConfigured()
|
|
return self.server_url ~= nil and self.username ~= nil and self.userkey ~= nil
|
|
end
|
|
|
|
-- Returns decoded_body on success, or nil, err_code, decoded_error_body.
|
|
-- err_code is a number for HTTP errors and a string for transport errors.
|
|
function BookOrbitApi:requestBlocking(method, path, body)
|
|
local sink = {}
|
|
local request = {
|
|
url = self.server_url .. path,
|
|
method = method,
|
|
sink = ltn12.sink.table(sink),
|
|
headers = {
|
|
["accept"] = "application/json",
|
|
["x-auth-user"] = self.username,
|
|
["x-auth-key"] = self.userkey,
|
|
},
|
|
}
|
|
|
|
if body then
|
|
local body_json, encode_err = rapidjson.encode(body, ENCODE_OPTIONS)
|
|
if not body_json then
|
|
return nil, "encode_error: " .. tostring(encode_err)
|
|
end
|
|
if #body_json > MAX_BODY_BYTES then
|
|
return nil, "body_too_large"
|
|
end
|
|
request.source = ltn12.source.string(body_json)
|
|
request.headers["Content-Type"] = "application/json"
|
|
request.headers["Content-Length"] = #body_json
|
|
end
|
|
|
|
socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT)
|
|
local code, _, status = socket.skip(1, http.request(request))
|
|
socketutil:reset_timeout()
|
|
|
|
if type(code) ~= "number" then
|
|
logger.dbg("BookOrbit: network error:", status or code)
|
|
return nil, tostring(status or code or "network_error")
|
|
end
|
|
|
|
local decoded, decode_err = decodeResponse(sink)
|
|
|
|
if code < 200 or code >= 300 then
|
|
return nil, code, decoded
|
|
end
|
|
|
|
if decode_err then
|
|
return nil, decode_err
|
|
end
|
|
|
|
return decoded or {}
|
|
end
|
|
|
|
function BookOrbitApi:request(method, path, body)
|
|
if not self.background_requests then
|
|
return self:requestBlocking(method, path, body)
|
|
end
|
|
|
|
local loaded, Trapper = pcall(require, "ui/trapper")
|
|
if not loaded or not Trapper:isWrapped() then
|
|
return self:requestBlocking(method, path, body)
|
|
end
|
|
|
|
-- An unmounted widget lets Trapper poll without intercepting reader input.
|
|
local trap_widget = {}
|
|
local completed, result = Trapper:dismissableRunInSubprocess(function()
|
|
local response, err, errbody = self:requestBlocking(method, path, body)
|
|
return { response = response, err = err, errbody = errbody }
|
|
end, trap_widget)
|
|
trap_widget.dismiss_callback = nil
|
|
if not completed then
|
|
return nil, "background_request_interrupted"
|
|
end
|
|
|
|
result = result or {}
|
|
return result.response, result.err, result.errbody
|
|
end
|
|
|
|
function BookOrbitApi:query(path, params)
|
|
if not params then return path end
|
|
|
|
local keys = {}
|
|
for key, value in pairs(params) do
|
|
if value ~= nil and value ~= "" then
|
|
table.insert(keys, key)
|
|
end
|
|
end
|
|
table.sort(keys)
|
|
if #keys == 0 then return path end
|
|
|
|
local parts = {}
|
|
for _, key in ipairs(keys) do
|
|
table.insert(parts, util.urlEncode(key) .. "=" .. util.urlEncode(tostring(params[key])))
|
|
end
|
|
return path .. "?" .. table.concat(parts, "&")
|
|
end
|
|
|
|
function BookOrbitApi:download(path, local_path, accept, progress_cb)
|
|
local out, open_err = io.open(local_path, "w")
|
|
if not out then
|
|
return nil, tostring(open_err or "open_error")
|
|
end
|
|
|
|
local file_sink = ltn12.sink.file(out)
|
|
local sink = file_sink
|
|
if progress_cb then
|
|
local received = 0
|
|
sink = function(chunk, err)
|
|
if chunk and chunk ~= "" then
|
|
received = received + #chunk
|
|
progress_cb(received)
|
|
end
|
|
return file_sink(chunk, err)
|
|
end
|
|
end
|
|
|
|
local request = {
|
|
url = self.server_url .. path,
|
|
method = "GET",
|
|
sink = sink,
|
|
headers = {
|
|
["accept"] = accept or "application/octet-stream",
|
|
["Accept-Encoding"] = "identity",
|
|
["x-auth-user"] = self.username,
|
|
["x-auth-key"] = self.userkey,
|
|
},
|
|
}
|
|
|
|
socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT)
|
|
local code, _, status = socket.skip(1, http.request(request))
|
|
socketutil:reset_timeout()
|
|
|
|
if code == 200 then
|
|
return true
|
|
end
|
|
|
|
util.removeFile(local_path)
|
|
if type(code) ~= "number" then
|
|
return nil, tostring(status or code or "network_error")
|
|
end
|
|
return nil, code
|
|
end
|
|
|
|
function BookOrbitApi:withDevice(body)
|
|
body.deviceId = self.device_id
|
|
body.deviceModel = self.device_model
|
|
body.pluginVersion = self.plugin_version
|
|
-- KOReader datetimes are local wall clock with no timezone; the server
|
|
-- needs our clock to mint device datetimes that are not in our future.
|
|
body.deviceTime = os.date("%Y-%m-%d %H:%M:%S")
|
|
return body
|
|
end
|
|
|
|
-- kosync-compatible endpoints (existing BookOrbit API, snake_case wire format)
|
|
|
|
function BookOrbitApi:auth()
|
|
return self:request("GET", "/koreader/users/auth")
|
|
end
|
|
|
|
function BookOrbitApi:getProgress(digest)
|
|
return self:request("GET", "/koreader/syncs/progress/" .. digest)
|
|
end
|
|
|
|
function BookOrbitApi:updateProgress(digest, percentage, progress, timestamp)
|
|
return self:request("PUT", "/koreader/syncs/progress", {
|
|
document = digest,
|
|
percentage = percentage,
|
|
progress = progress,
|
|
device = self.device_model,
|
|
device_id = self.device_id,
|
|
timestamp = timestamp,
|
|
})
|
|
end
|
|
|
|
-- BookOrbit plugin endpoints (camelCase wire format)
|
|
|
|
function BookOrbitApi:matchCheck(hashes, candidates)
|
|
local payload = { hashes = hashes }
|
|
if candidates then
|
|
payload.books = {}
|
|
for _, hash in ipairs(hashes) do
|
|
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,
|
|
})
|
|
end
|
|
end
|
|
return self:request("POST", "/koreader/plugin/match-check", self:withDevice(payload))
|
|
end
|
|
|
|
function BookOrbitApi:uploadPageStats(books)
|
|
return self:request("POST", "/koreader/plugin/page-stats", self:withDevice({ books = books }))
|
|
end
|
|
|
|
-- Deprecated one-way upload, kept as fallback for pre-0.4 servers.
|
|
function BookOrbitApi:uploadAnnotations(books)
|
|
return self:request("POST", "/koreader/plugin/annotations", self:withDevice({ books = books }))
|
|
end
|
|
|
|
function BookOrbitApi:exchangeAnnotations(books)
|
|
return self:request("POST", "/koreader/plugin/annotations/exchange", self:withDevice({ books = books }))
|
|
end
|
|
|
|
function BookOrbitApi:exchangeAck(books)
|
|
return self:request("POST", "/koreader/plugin/annotations/exchange-ack", self:withDevice({ books = books }))
|
|
end
|
|
|
|
function BookOrbitApi:uploadBookStates(books)
|
|
return self:request("POST", "/koreader/plugin/book-states", self:withDevice({ books = books }))
|
|
end
|
|
|
|
function BookOrbitApi:bulkProgress(items)
|
|
return self:request("POST", "/koreader/plugin/progress", self:withDevice({ items = items }))
|
|
end
|
|
|
|
function BookOrbitApi:sweepComplete(counts)
|
|
return self:request("POST", "/koreader/plugin/sweeps", self:withDevice({
|
|
booksMatched = counts.books_matched or 0,
|
|
pageStatsUploaded = counts.page_stats or 0,
|
|
annotationsUpserted = counts.annotations or 0,
|
|
}))
|
|
end
|
|
|
|
-- BookOrbit catalog endpoints
|
|
|
|
function BookOrbitApi:catalogRoot()
|
|
return self:request("GET", "/koreader/plugin/catalog/root")
|
|
end
|
|
|
|
function BookOrbitApi:catalogDashboard()
|
|
return self:request("GET", "/koreader/plugin/catalog/dashboard")
|
|
end
|
|
|
|
function BookOrbitApi:catalogDiscover()
|
|
return self:request("GET", "/koreader/plugin/catalog/dashboard/discover")
|
|
end
|
|
|
|
function BookOrbitApi:catalogSection(section, params)
|
|
return self:request("GET", self:query("/koreader/plugin/catalog/sections/" .. util.urlEncode(section), params))
|
|
end
|
|
|
|
function BookOrbitApi:catalogSetReadStatus(book_id, status)
|
|
return self:request("PUT", "/koreader/plugin/catalog/books/" .. tostring(book_id) .. "/read-status", { status = status })
|
|
end
|
|
|
|
function BookOrbitApi:catalogSetRating(book_id, rating)
|
|
if rating == nil then rating = rapidjson.null end
|
|
return self:request("PUT", "/koreader/plugin/catalog/books/" .. tostring(book_id) .. "/rating", { rating = rating })
|
|
end
|
|
|
|
function BookOrbitApi:catalogBooks(params)
|
|
return self:request("GET", self:query("/koreader/plugin/catalog/books", params))
|
|
end
|
|
|
|
function BookOrbitApi:catalogBook(book_id)
|
|
return self:request("GET", self:query("/koreader/plugin/catalog/books/" .. tostring(book_id), { deviceId = self.device_id }))
|
|
end
|
|
|
|
function BookOrbitApi:downloadCatalogFile(file_id, local_path, progress_cb)
|
|
return self:download("/koreader/plugin/catalog/files/" .. tostring(file_id) .. "/download", local_path, nil, progress_cb)
|
|
end
|
|
|
|
function BookOrbitApi:downloadCatalogThumbnail(book_id, local_path)
|
|
return self:download("/koreader/plugin/catalog/books/" .. tostring(book_id) .. "/thumbnail", local_path, "image/jpeg,image/*")
|
|
end
|
|
|
|
-- Plugin self-update endpoints
|
|
|
|
function BookOrbitApi:getPluginVersion()
|
|
return self:request("GET", "/koreader/plugin/version")
|
|
end
|
|
|
|
function BookOrbitApi:downloadPluginUpdate(local_path, progress_cb)
|
|
return self:download("/koreader/plugin/package", local_path, "application/zip", progress_cb)
|
|
end
|
|
|
|
return BookOrbitApi
|