Files
neon 80244ea4e2 feat(koreader): rebuild the plugin interface and fix four-way annotation sync
Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned
book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks
now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and
reading progress and narration hand off cleanly between devices.
2026-10-20 16:05:27 -06:00

200 lines
7.5 KiB
Lua

--[[--
Parent side of a background file transfer.
The child process owns the socket and writes into a temporary file inside a
dedicated directory under the authorized destination root, so the publishing
rename never crosses a filesystem boundary. Byte progress reaches the UI
through generation-scoped snapshot files that the parent polls from short
scheduled callbacks.
Publishing stays with the parent. Dismissing a Trapper subprocess cannot stop a
child that has already started renaming, which is harmless for a cover but
wrong for a book the user just cancelled, so the parent re-checks the
generation and the resolved destination before it publishes anything.
]]
local UIManager = require("ui/uimanager")
local lfs = require("libs/libkoreader-lfs")
local util = require("util")
local TransferPolicy = require("bookorbit_transfer_policy")
local TransferProgress = require("bookorbit_transfer_progress")
local TEMP_DIR_NAME = ".bookorbit-tmp"
local POLL_INTERVAL = 0.5
local STALE_TEMP_AGE = 24 * 60 * 60
local MAX_STALE_SWEEP = 64
local Transfer = {}
Transfer.TEMP_DIR_NAME = TEMP_DIR_NAME
Transfer.POLL_INTERVAL = POLL_INTERVAL
local function normalize(path)
local normalized = tostring(path or ""):gsub("\\", "/"):gsub("/+", "/")
if normalized == "/" then return normalized end
return normalized:gsub("/$", "")
end
function Transfer.tempDir(root)
local normalized_root = normalize(root)
return (normalized_root ~= "/" and normalized_root or "") .. "/" .. TEMP_DIR_NAME
end
function Transfer.ensureTempDir(root)
local dir = Transfer.tempDir(root)
if not Transfer.isInsideRoot(root, dir) then return nil end
if lfs.attributes(dir, "mode") == "directory" then return dir end
util.makePath(dir)
if lfs.attributes(dir, "mode") ~= "directory" then return nil end
return dir
end
-- Confirms a resolved destination still lives under the authorized root and
-- contains no traversal segment.
function Transfer.isInsideRoot(root, path)
local normalized_root = normalize(root)
local normalized_path = normalize(path)
if normalized_root == "" or normalized_path == "" then return false end
if normalized_root == "/" then
if normalized_path == "/" or normalized_path:sub(1, 1) ~= "/" then return false end
elseif normalized_path:sub(1, #normalized_root + 1) ~= normalized_root .. "/" then
return false
end
for segment in normalized_path:gmatch("[^/]+") do
if segment == ".." then return false end
end
-- The configured root may itself be a link, but descendants must not
-- redirect staging or publication outside that authorized directory.
local current = normalized_root == "/" and "" or normalized_root
local relative = normalized_path:sub(#current + 2)
for segment in relative:gmatch("[^/]+") do
current = current .. "/" .. segment
if lfs.symlinkattributes(current, "mode") == "link" then return false end
end
return true
end
-- Drops leftovers from a killed run. Bounded per pass so a large cache cannot
-- turn maintenance into its own stall.
function Transfer.sweepStale(root, opts)
opts = opts or {}
local dir = Transfer.tempDir(root)
if not Transfer.isInsideRoot(root, dir) then return 0 end
if lfs.attributes(dir, "mode") ~= "directory" then return 0 end
local now = opts.now or os.time()
local max_age = opts.max_age or STALE_TEMP_AGE
local budget = opts.limit or MAX_STALE_SWEEP
local removed = 0
local ok, iterator, dir_obj = pcall(lfs.dir, dir)
if not ok or not iterator then return 0 end
for name in iterator, dir_obj do
if budget <= 0 then break end
if name ~= "." and name ~= ".." then
local path = dir .. "/" .. name
local modified = lfs.attributes(path, "modification")
if type(modified) ~= "number" or now - modified >= max_age then
util.removeFile(path)
removed = removed + 1
end
budget = budget - 1
end
end
return removed
end
local function pollProgress(state)
if state.stopped then return end
local snapshot = TransferProgress.read(state.progress_path, { generation = state.generation })
if snapshot and state.on_progress then
state.on_progress(snapshot.received, snapshot.total)
end
if state.stopped then return end
UIManager:scheduleIn(POLL_INTERVAL, function() pollProgress(state) end)
end
-- Runs one transfer to completion. Must be called from inside a Trapper
-- coroutine so the request can own a background subprocess.
--
-- opts: perform(download_opts) -> (result, err), destination, root,
-- generation, expected_bytes, on_progress(received, total), is_current(),
-- trap_widget.
function Transfer.run(opts)
local root = opts.root
if not Transfer.isInsideRoot(root, opts.destination) then
return nil, "unsafe_destination"
end
local dir = Transfer.ensureTempDir(root)
if not dir then return nil, "temp_dir_failed" end
local base = string.format("bo_%d_%d", opts.generation or 0, os.time())
local temp_path = dir .. "/" .. base .. ".part"
local progress_path = dir .. "/" .. base .. ".progress"
if not Transfer.isInsideRoot(root, temp_path) or not Transfer.isInsideRoot(root, progress_path) then
return nil, "unsafe_destination"
end
local state = {
progress_path = progress_path,
generation = opts.generation,
on_progress = opts.on_progress,
stopped = false,
}
UIManager:scheduleIn(POLL_INTERVAL, function() pollProgress(state) end)
local block_timeout, total_timeout = TransferPolicy.timeouts(opts.expected_bytes)
local ok, result, err = pcall(opts.perform, {
temp_path = temp_path,
progress_path = progress_path,
progress_generation = opts.generation,
expected_bytes = opts.expected_bytes,
block_timeout = block_timeout,
total_timeout = total_timeout,
publish = "parent",
hash = opts.hash,
trap_widget = opts.trap_widget,
-- Only reached when no subprocess could be forked; then the parent is
-- blocked anyway and the callback is the sole progress channel.
progress_cb = opts.on_progress and function(received)
opts.on_progress(received, opts.expected_bytes)
end or nil,
})
state.stopped = true
TransferProgress.cleanup(progress_path)
if not ok then
util.removeFile(temp_path)
error(result, 0)
end
if not result then
util.removeFile(temp_path)
return nil, err
end
local completed_path = type(result) == "table" and result.temp_path or temp_path
if opts.is_current and not opts.is_current() then
util.removeFile(completed_path)
return nil, "cancelled"
end
if not Transfer.isInsideRoot(root, opts.destination) then
util.removeFile(completed_path)
return nil, "unsafe_destination"
end
local expected_hash = opts.expected_hash or (type(result) == "table" and result.server_hash)
if expected_hash then
local digest = type(result) == "table" and result.hash
if not digest then local ok, hash = pcall(util.partialMD5, completed_path); digest = ok and hash or nil end
if digest ~= expected_hash then util.removeFile(completed_path); return nil, "hash_mismatch" end
end
local renamed = os.rename(completed_path, opts.destination)
if not renamed then
util.removeFile(completed_path)
return nil, "publish_failed"
end
return true, nil, type(result) == "table" and result or {}
end
return Transfer