Files
bookorbit/koreader-plugin/bookorbit.koplugin/bookorbit_api.lua
T
Irohoho 900514c2b3 fix(koreader): respect KOReader global HTTP proxy settings (#1562)
* fix(koreader): respect KOReader global HTTP proxy settings

Allow KOReader's Network -> Proxy settings (stored in G_reader_settings)
to be respected for both JSON API requests and binary downloads.
Also support explicit proxy in BookOrbitApi options, and route Tailscale
tailnet addresses (100.x.y.z and *.ts.net) through the local userspace
HTTP proxy (127.0.0.1:1056) when running on e-ink devices lacking TUN.

Fixes #753

* fix(koreader): address review feedback on host parsing and HTTPS proxy safety

* fix(koreader): simplify getProxy to respect KOReader proxy settings without vendor fallbacks

* fix: tunnel KOReader HTTPS through configured HTTP proxies

Route HTTPS requests through CONNECT so proxy-only servers stay reachable without exposing BookOrbit credentials to the proxy. Restrict redirects to the original host and allow secure upgrades for API calls and downloads. Bump the plugin version so installed clients discover the fix.

---------

Co-authored-by: Andrej <acikarski14@gmail.com>
Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
2026-09-26 11:15:47 -06:00

757 lines
28 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 TransferPolicy = require("bookorbit_transfer_policy")
local TransferProgress = require("bookorbit_transfer_progress")
local Proxy = require("bookorbit_proxy")
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
-- 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 }
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)
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 function encodeSubprocessResult(body, err, errbody)
local ok, encoded = pcall(rapidjson.encode, {
body = body,
err = err,
errbody = errbody,
})
if ok and type(encoded) == "string" and encoded ~= "" then
return encoded
end
return '{"err":"subprocess_result_encode_failed"}'
end
local function decodeSubprocessResult(encoded)
if type(encoded) ~= "string" or encoded == "" then
return nil, "subprocess_no_result"
end
local decoded = decodeResponse({ encoded })
if type(decoded) ~= "table" then
return nil, "subprocess_invalid_result"
end
return decoded
end
local function parseHttpUrl(value)
local scheme, authority = tostring(value or ""):match("^(https?)://([^/%?#]+)")
if not scheme or not authority or authority:find("@", 1, true) then return nil end
local host, port
if authority:sub(1, 1) == "[" then
host, port = authority:match("^(%b[]):?(%d*)$")
else
host, port = authority:match("^([^:]+):?(%d*)$")
end
if not host or host == "" then return nil end
port = port ~= "" and tonumber(port) or (scheme == "https" and 443 or 80)
if not port then return nil end
return {
scheme = scheme,
authority = authority,
host = host:lower(),
port = port,
}
end
local function absoluteUrl(base, location)
if location:match("^https?://") then return location end
local parsed = parseHttpUrl(base)
if not parsed then return nil end
if location:sub(1, 1) == "?" then
return base:gsub("[?#].*$", "") .. location
end
if location:sub(1, 1) == "#" then
return base:gsub("#.*$", "") .. location
end
if location:sub(1, 2) == "//" then
return parsed.scheme .. ":" .. location
end
local origin = parsed.scheme .. "://" .. parsed.authority
if location:sub(1, 1) == "/" then return origin .. location end
local directory = base:match("^(.*)/") or origin
return directory .. "/" .. location
end
local function safeRequestRedirect(initial_url, current_url, location)
if type(location) ~= "string" or location == "" then return nil end
local resolved = absoluteUrl(current_url, location:gsub("%s", ""))
local initial = parseHttpUrl(initial_url)
local current = parseHttpUrl(current_url)
local target = parseHttpUrl(resolved)
if not initial or not current or not target or target.host ~= initial.host then return nil end
if target.scheme == current.scheme and target.port == current.port then return resolved end
if current.scheme == "http" and target.scheme == "https" then return resolved end
return nil
end
local function forkSafeServerUrl(value)
local scheme, authority, suffix = tostring(value or ""):match("^(https?)://([^/%?#]+)(.*)$")
if scheme ~= "http" or not authority then return value end
local port = authority:lower():match("^localhost:(%d+)$")
if authority:lower() == "localhost" then
return "http://127.0.0.1" .. suffix
elseif port then
return "http://127.0.0.1:" .. port .. suffix
end
return value
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)
opts = opts or {}
return setmetatable({
server_url = forkSafeServerUrl(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,
proxy = opts.proxy,
}, BookOrbitApi)
end
-- Resolves the HTTP proxy for a target URL.
-- Prefers an explicit proxy option, then KOReader's enabled global HTTP proxy.
function BookOrbitApi:getProxy(target_url)
local url = (target_url and tostring(target_url):match("^https?://")) and tostring(target_url) or self.server_url
local parsed = parseHttpUrl(url)
if not parsed then return nil end
if self.proxy and self.proxy ~= "" then
return self.proxy
end
if G_reader_settings and G_reader_settings:isTrue("http_proxy_enabled") then
local p = G_reader_settings:readSetting("http_proxy")
if p and p ~= "" then
return p
end
end
return nil
end
local function sendHttpRequest(request)
if not request.url:lower():match("^https://") then
return http.request(request)
end
local transport_request = {}
for key, value in pairs(request) do transport_request[key] = value end
if request.proxy then
transport_request.create = Proxy.connectSocket(request.proxy)
transport_request.proxy = nil
end
-- LuaSocket's global proxy overrides a custom HTTPS socket unless cleared.
local global_proxy = http.PROXY
http.PROXY = nil
local ok, result, code, headers, status = pcall(http.request, transport_request)
http.PROXY = global_proxy
if not ok then return nil, tostring(result) end
return result, code, headers, status
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, extra_headers)
local initial_url = self.server_url .. path
local request = {
url = initial_url,
method = method,
redirect = false,
headers = {
["accept"] = "application/json",
["x-auth-user"] = self.username,
["x-auth-key"] = self.userkey,
},
}
for name, value in pairs(extra_headers or {}) do
request.headers[name] = value
end
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
local current_url = initial_url
for redirect_count = 0, MAX_DOWNLOAD_REDIRECTS do
local sink = {}
request.url = current_url
request.sink = ltn12.sink.table(sink)
request.proxy = self:getProxy(current_url)
socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT)
local code, headers, status = socket.skip(1, sendHttpRequest(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
if not request.source and (method == "GET" or method == "HEAD")
and (code == 301 or code == 302 or code == 303 or code == 307 or code == 308) then
if redirect_count >= MAX_DOWNLOAD_REDIRECTS then
return nil, "too_many_redirects"
end
local location = headers and (headers.location or headers.Location)
local redirected = safeRequestRedirect(initial_url, current_url, location)
if not redirected then return nil, "unsafe_redirect" end
current_url = redirected
else
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
end
return nil, "too_many_redirects"
end
-- True when this call may fork its own background worker: the client is in
-- background mode, a Trapper coroutine is driving us, and no enclosing
-- runInSubprocess already owns a child for this work.
function BookOrbitApi:canForkSubprocess()
if not self.background_requests then return false end
if (self.subprocess_depth or 0) > 0 then return false end
local loaded, Trapper = pcall(require, "ui/trapper")
if not loaded or not Trapper:isWrapped() then return false end
return true
end
-- Owns exactly one background subprocess for the duration of fn. Calls that
-- fn makes back into this client see a non-zero depth in the forked child and
-- run inline, so a request is never wrapped by two nested workers.
--
-- fn must return (body, err, errbody); they are marshalled back through the
-- subprocess. Returns completed, { body, err, errbody }; completed is false
-- when the user dismissed the request.
function BookOrbitApi:runInSubprocess(fn, trap_widget)
if not self:canForkSubprocess() then
local ok, body, err, errbody = pcall(fn)
if not ok then return true, { err = tostring(body) } end
return true, { body = body, err = err, errbody = errbody }
end
local Trapper = require("ui/trapper")
-- An unmounted widget lets Trapper poll without intercepting reader input.
local widget = trap_widget or {}
local completed, encoded = Trapper:dismissableRunInSubprocess(function()
self.subprocess_depth = (self.subprocess_depth or 0) + 1
local ok, body, err, errbody = pcall(fn)
self.subprocess_depth = self.subprocess_depth - 1
if not ok then
return encodeSubprocessResult(nil, tostring(body))
end
return encodeSubprocessResult(body, err, errbody)
end, widget, true)
if type(widget) == "table" then widget.dismiss_callback = nil end
if not completed then return false end
local result, decode_err = decodeSubprocessResult(encoded)
if not result then
return true, { err = decode_err }
end
return true, result
end
function BookOrbitApi:request(method, path, body, extra_headers)
if not self:canForkSubprocess() then
return self:requestBlocking(method, path, body, extra_headers)
end
local completed, result = self:runInSubprocess(function()
return self:requestBlocking(method, path, body, extra_headers)
end)
if not completed then
return nil, "background_request_interrupted"
end
return result.body, 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
-- Downloads to a temporary file and publishes it with an atomic rename, so a
-- timeout, an oversized body or a cancelled generation can never leave a
-- half-written file where a complete one is expected.
--
-- opts: accept, progress_cb, progress_path, progress_generation, temp_path,
-- max_bytes, expect_content_type, block_timeout, total_timeout, expected_bytes,
-- publish. With publish = "parent" the complete temporary file is handed back
-- instead of renamed, so a cancelled generation cannot be published by a child
-- that was already finishing.
function BookOrbitApi:downloadBlocking(path, local_path, opts)
opts = opts or {}
local temp_path = opts.temp_path or (local_path .. ".part")
local max_bytes = opts.max_bytes or TransferPolicy.maxBytes(opts.expected_bytes)
local progress_cb = opts.progress_cb
local progress_writer = opts.progress_path and TransferProgress.writer(opts.progress_path, {
generation = opts.progress_generation,
total = tonumber(opts.expected_bytes) or 0,
})
local initial_url = self.server_url .. path
local current_url = initial_url
local total_received = 0
local function fail(out, err)
if out then pcall(function() out:close() end) end
util.removeFile(temp_path)
return nil, err
end
for redirect_count = 0, MAX_DOWNLOAD_REDIRECTS do
local out, open_err = io.open(temp_path, "w")
if not out then
return nil, tostring(open_err or "open_error")
end
local received = 0
local too_large = false
local file_sink = ltn12.sink.file(out)
local sink = function(chunk, err)
if chunk and chunk ~= "" then
received = received + #chunk
total_received = total_received + #chunk
if received > max_bytes or total_received > max_bytes then
too_large = true
return nil, "response_too_large"
end
if progress_cb then progress_cb(received) end
if progress_writer then progress_writer(received, false) end
end
return file_sink(chunk, err)
end
local request = {
url = current_url,
method = "GET",
sink = sink,
redirect = false,
headers = {
["accept"] = opts.accept or "application/octet-stream",
["Accept-Encoding"] = "identity",
["x-auth-user"] = self.username,
["x-auth-key"] = self.userkey,
},
}
request.proxy = self:getProxy(current_url)
socketutil:set_timeout(
opts.block_timeout or socketutil.FILE_BLOCK_TIMEOUT,
opts.total_timeout or socketutil.FILE_TOTAL_TIMEOUT)
local code, headers, status = socket.skip(1, sendHttpRequest(request))
socketutil:reset_timeout()
if too_large then return fail(out, "response_too_large") end
if type(code) ~= "number" then
return fail(out, tostring(status or code or "network_error"))
end
if code == 301 or code == 302 or code == 303 or code == 307 or code == 308 then
pcall(function() out:close() end)
util.removeFile(temp_path)
if redirect_count >= MAX_DOWNLOAD_REDIRECTS then
return nil, "too_many_redirects"
end
local location = headers and (headers.location or headers.Location)
local redirected = safeRequestRedirect(initial_url, current_url, location)
if not redirected then return nil, "unsafe_redirect" end
current_url = redirected
else
if code ~= 200 then return fail(out, code) end
if received == 0 then return fail(out, "empty_response") end
local expected_type = opts.expect_content_type
if expected_type then
local content_type = headers and (headers["content-type"] or headers["Content-Type"])
local media_type = content_type and tostring(content_type):lower():match("^%s*([^;]+)")
if not media_type or media_type:sub(1, #expected_type) ~= expected_type then
return fail(out, "unexpected_content_type")
end
end
if progress_writer then progress_writer(received, true) end
-- The parent owns publishing for user-cancellable transfers: a
-- dismissed subprocess cannot be stopped once it starts renaming,
-- which is harmless for a cover but wrong for a cancelled book.
if opts.publish == "parent" then
local result = { temp_path = temp_path, bytes = received }
-- Hashing here keeps the digest off the UI thread and lets the
-- caller compare it to the manifest without a match request.
if opts.hash == "partial_md5" then
local hashed, digest = pcall(util.partialMD5, temp_path)
result.hash = hashed and digest or nil
end
return result
end
if temp_path == local_path then return true end
local renamed, rename_err = os.rename(temp_path, local_path)
if not renamed then
return fail(out, tostring(rename_err or "publish_failed"))
end
return true
end
end
return nil, "too_many_redirects"
end
-- Runs the transfer in a background subprocess when this client owns no worker
-- yet. A forked child cannot reach the parent's UI, so live byte progress
-- travels through the snapshot file named by opts.progress_path; only a caller
-- that has nothing but an in-process progress_cb stays blocking.
function BookOrbitApi:download(path, local_path, opts)
opts = opts or {}
local blocking = opts.background == false
or (opts.progress_cb ~= nil and opts.progress_path == nil)
or not self:canForkSubprocess()
if blocking then
return self:downloadBlocking(path, local_path, opts)
end
-- A child's progress_cb would touch parent-only UI state from the forked
-- process; the snapshot file is the only channel back.
local child_opts = {}
for key, value in pairs(opts) do child_opts[key] = value end
child_opts.progress_cb = nil
local completed, result = self:runInSubprocess(function()
return self:downloadBlocking(path, local_path, child_opts)
end, opts.trap_widget)
if not completed then
return nil, "cancelled"
end
return result.body, result.err
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 = {} }
if candidates then
payload.books = {}
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 = boundedUtf8(cand.title, MATCH_TITLE_MAX_BYTES),
authors = boundedUtf8(cand.authors, MATCH_AUTHORS_MAX_BYTES),
lastOpen = nonNegativeInteger(cand.last_open),
source = validMatchSource(cand.source),
bookFileId = nonNegativeInteger(cand.book_file_id),
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
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:exchangeBookmarks(books)
return self:request("POST", "/koreader/plugin/bookmarks/exchange", self:withDevice({ books = books }))
end
function BookOrbitApi:exchangeBookmarksAck(books)
return self:request("POST", "/koreader/plugin/bookmarks/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
-- The dashboard body carries the stats, Continue reading and Discover the
-- shelves are built from. Each configured shelf is then fetched through the
-- ordinary catalog endpoints, so nothing here is section-aware.
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
-- One server-composed dashboard section (want-to-read, up-next-in-series).
-- Only valid against servers advertising the catalogDashboardSections
-- capability; older ones answer 404.
function BookOrbitApi:catalogDashboardSection(section_type)
return self:request("GET", "/koreader/plugin/catalog/dashboard/sections/" .. util.urlEncode(tostring(section_type)))
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
-- Cursor-paginated bulk download manifest. One page carries everything
-- selection and transfer need, so a bulk run issues no per-book detail request.
function BookOrbitApi:catalogManifest(params)
params = params or {}
params.deviceId = params.deviceId or self.device_id
return self:request("GET", self:query("/koreader/plugin/catalog/manifest", 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, opts)
return self:download("/koreader/plugin/catalog/files/" .. tostring(file_id) .. "/download", local_path, opts)
end
function BookOrbitApi:downloadCatalogThumbnail(book_id, local_path, opts)
opts = opts or {}
opts.accept = "image/jpeg,image/*"
opts.expect_content_type = "image/"
return self:download("/koreader/plugin/catalog/books/" .. tostring(book_id) .. "/thumbnail", local_path, opts)
end
-- Plugin self-update endpoints
function BookOrbitApi:getPluginVersion()
local headers = {}
local valid_device_id = type(self.device_id) == "string"
and #self.device_id <= 100
and self.device_id:match("^[A-Za-z0-9-]+$") ~= nil
local valid_plugin_version = type(self.plugin_version) == "string"
and #self.plugin_version <= 20
and self.plugin_version:find("[\r\n]") == nil
if valid_device_id and valid_plugin_version then
headers["x-bookorbit-device-id"] = self.device_id
headers["x-bookorbit-plugin-version"] = self.plugin_version
end
return self:request("GET", "/koreader/plugin/version", nil, headers)
end
function BookOrbitApi:downloadPluginUpdate(local_path, opts)
opts = opts or {}
opts.accept = "application/zip"
return self:download("/koreader/plugin/package", local_path, opts)
end
return BookOrbitApi