From 0506ffe2896584332c9bf7badab9e8c0d3377bcf Mon Sep 17 00:00:00 2001 From: poire-z Date: Fri, 16 Feb 2024 12:24:31 +0100 Subject: [PATCH] HttpInspector: new plugin for developers to inspect KOReader (#11457) Can be used to inspect the state of the objects in a running KOReader. It can also be used to execute actions (like the ones available to associate to a gesture) with HTTP requests from a remote computer/devices/gadgets. The TCP server side is provided either with a new ZeroMQ StreamMessageQueueServer (thanks bneo99), or with a LuaSocket based SimpleTCPServer. Minor UIManager tweak to avoid uneeded inputevent when such a ZeroMQ module is running. --- frontend/ui/message/simpletcpserver.lua | 68 + .../ui/message/streammessagequeueserver.lua | 86 ++ frontend/ui/uimanager.lua | 8 +- plugins/httpinspector.koplugin/_meta.lua | 6 + plugins/httpinspector.koplugin/main.lua | 1271 +++++++++++++++++ 5 files changed, 1436 insertions(+), 3 deletions(-) create mode 100644 frontend/ui/message/simpletcpserver.lua create mode 100644 frontend/ui/message/streammessagequeueserver.lua create mode 100644 plugins/httpinspector.koplugin/_meta.lua create mode 100644 plugins/httpinspector.koplugin/main.lua diff --git a/frontend/ui/message/simpletcpserver.lua b/frontend/ui/message/simpletcpserver.lua new file mode 100644 index 000000000..1ec189812 --- /dev/null +++ b/frontend/ui/message/simpletcpserver.lua @@ -0,0 +1,68 @@ +local socket = require("socket") +local logger = require("logger") + +-- Reference: +-- https://lunarmodules.github.io/luasocket/tcp.html + +-- Drop-in alternative to streammessagequeueserver.lua, using +-- LuaSocket instead of ZeroMQ. +-- This SimpleTCPServer is still tied to HTTP, expecting lines of headers, +-- a blank like marking the end of the input request. + +local SimpleTCPServer = { + host = nil, + port = nil, +} + +function SimpleTCPServer:new(o) + o = o or {} + setmetatable(o, self) + self.__index = self + if o.init then o:init() end + return o +end + +function SimpleTCPServer:start() + self.server = socket.bind(self.host, self.port) + self.server:settimeout(0.01) -- set timeout (10ms) + logger.dbg("SimpleTCPServer: Server listening on port " .. self.port) +end + +function SimpleTCPServer:stop() + self.server:close() +end + +function SimpleTCPServer:waitEvent() + local client = self.server:accept() -- wait for a client to connect + if client then + -- We expect to get all headers in 100ms. We will block during this timeframe. + client:settimeout(0.1, "t") + local lines = {} + while true do + local data = client:receive("*l") -- read a line from input + if not data then -- timeout + client:close() + break + end + if data == "" then -- proper empty line after request headers + table.insert(lines, data) -- keep it in content + data = table.concat(lines, "\r\n") + logger.dbg("SimpleTCPServer: Received data: ", data) + -- Give us more time to process the request and send the response + client:settimeout(0.5, "t") + self.receiveCallback(data, client) + -- This should call SimpleTCPServer:send() to send + -- the response and close this connection. + else + table.insert(lines, data) + end + end + end +end + +function SimpleTCPServer:send(data, client) + client:send(data) -- send the response back to the client + client:close() -- close the connection to the client +end + +return SimpleTCPServer diff --git a/frontend/ui/message/streammessagequeueserver.lua b/frontend/ui/message/streammessagequeueserver.lua new file mode 100644 index 000000000..a55a38f5e --- /dev/null +++ b/frontend/ui/message/streammessagequeueserver.lua @@ -0,0 +1,86 @@ +local ffi = require("ffi") +local logger = require("logger") +local MessageQueue = require("ui/message/messagequeue") + +local _ = require("ffi/zeromq_h") +local czmq = ffi.load("libs/libczmq.so.1") +local C = ffi.C + +local StreamMessageQueueServer = MessageQueue:extend{ + host = nil, + port = nil, +} + +function StreamMessageQueueServer:start() + self.context = czmq.zctx_new() + self.socket = czmq.zsocket_new(self.context, C.ZMQ_STREAM) + self.poller = czmq.zpoller_new(self.socket, nil) + local endpoint = string.format("tcp://%s:%d", self.host, self.port) + logger.dbg("StreamMessageQueueServer: Binding to endpoint", endpoint) + local rc = czmq.zsocket_bind(self.socket, endpoint) + -- If success, rc is port number + if rc == -1 then + logger.err("StreamMessageQueueServer: Cannot bind to ", endpoint) + end +end + +function StreamMessageQueueServer:stop() + if self.poller ~= nil then + czmq.zpoller_destroy(ffi.new('zpoller_t *[1]', self.poller)) + end + if self.socket ~= nil then + czmq.zsocket_destroy(self.context, self.socket) + end + if self.context ~= nil then + czmq.zctx_destroy(ffi.new('zctx_t *[1]', self.context)) + end +end + +function StreamMessageQueueServer:handleZframe(frame) + local size = czmq.zframe_size(frame) + local data = nil + if size > 0 then + local frame_data = czmq.zframe_data(frame) + if frame_data ~= nil then + data = ffi.string(frame_data, size) + end + end + czmq.zframe_destroy(ffi.new('zframe_t *[1]', frame)) + return data +end + +function StreamMessageQueueServer:waitEvent() + local request, id + while czmq.zpoller_wait(self.poller, 0) ~= nil do + -- See about ZMQ_STREAM and these 2 frames at http://hintjens.com/blog:42 + local id_frame = czmq.zframe_recv(self.socket) + if id_frame ~= nil then + id = id_frame + end + + local frame = czmq.zframe_recv(self.socket) + if frame ~= nil then + local data = self:handleZframe(frame) + if data then + logger.dbg("StreamMessageQueueServer: Received data: ", data) + request = data + end + end + end + if self.receiveCallback and request ~= nil then + self.receiveCallback(request, id) + end +end + +function StreamMessageQueueServer:send(data, id_frame) + czmq.zframe_send(ffi.new('zframe_t *[1]', id_frame), self.socket, C.ZFRAME_MORE + C.ZFRAME_REUSE) + czmq.zmq_send(self.socket, ffi.cast("unsigned char*", data), #data, C.ZFRAME_MORE) + -- Note: We can't use czmq.zstr_send(self.socket, data), which would stop on the first + -- null byte in data (Lua strings can have null bytes inside). + + -- Close connection + czmq.zframe_send(ffi.new('zframe_t *[1]', id_frame), self.socket, C.ZFRAME_MORE) + czmq.zmq_send(self.socket, nil, 0, 0) +end + +return StreamMessageQueueServer diff --git a/frontend/ui/uimanager.lua b/frontend/ui/uimanager.lua index 412e95fd7..2bae82064 100644 --- a/frontend/ui/uimanager.lua +++ b/frontend/ui/uimanager.lua @@ -1439,11 +1439,13 @@ end -- Process all pending events on all registered ZMQs. function UIManager:processZMQs() - if self._zeromqs[1] then - self.event_hook:execute("InputEvent") - end + local sent_InputEvent = false for _, zeromq in ipairs(self._zeromqs) do for input_event in zeromq.waitEvent, zeromq do + if not sent_InputEvent then + self.event_hook:execute("InputEvent") + sent_InputEvent = true + end self:handleInputEvent(input_event) end end diff --git a/plugins/httpinspector.koplugin/_meta.lua b/plugins/httpinspector.koplugin/_meta.lua new file mode 100644 index 000000000..5f0a38d2d --- /dev/null +++ b/plugins/httpinspector.koplugin/_meta.lua @@ -0,0 +1,6 @@ +local _ = require("gettext") +return { + name = "httpinspector", + fullname = _("HTTP KOReader Inspector"), + description = _([[Allow browsing KOReader internal objects over HTTP. This is aimed at developers, and may pose some security risks. Only enable this on networks you can trust.]]), +} diff --git a/plugins/httpinspector.koplugin/main.lua b/plugins/httpinspector.koplugin/main.lua new file mode 100644 index 000000000..b0256fde3 --- /dev/null +++ b/plugins/httpinspector.koplugin/main.lua @@ -0,0 +1,1271 @@ +--[[-- +This plugin allows for inspecting KOReader's internal objects, +calling methods, sending events... over HTTP. +--]]-- + +local DataStorage = require("datastorage") +local Device = require("device") +local UIManager = require("ui/uimanager") +local WidgetContainer = require("ui/widget/container/widgetcontainer") +local Event = require("ui/event") +local ffiUtil = require("ffi/util") +local logger = require("logger") +local util = require("util") +local _ = require("gettext") +local T = require("ffi/util").template + +local HttpInspector = WidgetContainer:extend{ + name = "httpinspector", +} + +-- A plugin gets instantiated on each document load and reader/FM switch. +-- Ensure autostart only on KOReader startup, and keep the running state +-- across document load and reader/FM switch. +local should_run = G_reader_settings:isTrue("httpinspector_autostart") + +function HttpInspector:init() + self.port = G_reader_settings:readSetting("httpinspector_port", "8080") + if should_run then + -- Delay this until after all plugins are loaded + UIManager:nextTick(function() + self:start() + end) + end + self.ui.menu:registerToMainMenu(self) +end + +function HttpInspector:isRunning() + return self.http_socket ~= nil +end + +function HttpInspector:onEnterStandby() + logger.dbg("HttpInspector: onEnterStandby") + if self:isRunning() then + self:stop() + end +end + +function HttpInspector:onSuspend() + logger.dbg("HttpInspector: onSuspend") + if self:isRunning() then + self:stop() + end +end + +function HttpInspector:onExit() + logger.dbg("HttpInspector: onExit") + if self:isRunning() then + self:stop() + end +end + +function HttpInspector:onCloseWidget() + logger.dbg("HttpInspector: onCloseWidget") + if self:isRunning() then + self:stop() + end +end + +function HttpInspector:onLeaveStandby() + logger.dbg("HttpInspector: onLeaveStandby") + if should_run and not self:isRunning() then + self:start() + end +end + +function HttpInspector:onResume() + logger.dbg("HttpInspector: onResume") + if should_run and not self:isRunning() then + self:start() + end +end + +function HttpInspector:start() + logger.dbg("HttpInspector: Starting server...") + + -- Make a hole in the Kindle's firewall + if Device:isKindle() then + os.execute(string.format("%s %s %s", + "iptables -A INPUT -p tcp --dport", self.port, + "-m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT")) + os.execute(string.format("%s %s %s", + "iptables -A OUTPUT -p tcp --sport", self.port, + "-m conntrack --ctstate ESTABLISHED -j ACCEPT")) + end + + -- Using a simple LuaSocket based TCP server instead of a ZeroMQ based one + -- seems to solve strange issues with Chrome. + -- local ServerClass = require("ui/message/streammessagequeueserver") + local ServerClass = require("ui/message/simpletcpserver") + self.http_socket = ServerClass:new{ + host = "*", + port = self.port, + receiveCallback = function(data, id) return self:onRequest(data, id) end, + } + self.http_socket:start() + self.http_messagequeue = UIManager:insertZMQ(self.http_socket) + + logger.dbg("HttpInspector: Server listening on port " .. self.port) +end + +function HttpInspector:stop() + logger.dbg("HttpInspector: Stopping server...") + + -- Plug the hole in the Kindle's firewall + if Device:isKindle() then + os.execute(string.format("%s %s %s", + "iptables -D INPUT -p tcp --dport", self.port, + "-m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT")) + os.execute(string.format("%s %s %s", + "iptables -D OUTPUT -p tcp --sport", self.port, + "-m conntrack --ctstate ESTABLISHED -j ACCEPT")) + end + + if self.http_socket then + self.http_socket:stop() + self.http_socket = nil + end + if self.http_messagequeue then + UIManager:removeZMQ(self.http_messagequeue) + self.http_messagequeue = nil + end + + logger.dbg("HttpInspector: Server stopped.") +end + +function HttpInspector:addToMainMenu(menu_items) + menu_items.httpremote = { + text = _("KOReader HTTP inspector"), + sorting_hint = ("more_tools"), + sub_item_table = { + { + text_func = function() + if self:isRunning() then + return _("Stop HTTP server") + else + return _("Start HTTP server") + end + end, + keep_menu_open = true, + callback = function(touchmenu_instance) + if self:isRunning() then + should_run = false + self:stop() + else + should_run = true + self:start() + end + touchmenu_instance:updateItems() + end, + }, + { + text_func = function() + if self:isRunning() then + return T(_("Listening on port %1"), self.port) + else + return _("Not running") + end + end, + enabled_func = function() + return self:isRunning() + end, + separator = true, + }, + { + text = _("Auto start HTTP server"), + checked_func = function() + return G_reader_settings:isTrue("httpinspector_autostart") + end, + callback = function() + G_reader_settings:flipNilOrFalse("httpinspector_autostart") + end, + }, + { + text_func = function() + return T(_("Port: %1"), self.port) + end, + keep_menu_open = true, + callback = function(touchmenu_instance) + local InputDialog = require("ui/widget/inputdialog") + local port_dialog + port_dialog = InputDialog:new{ + title = _("Set custom port"), + input = self.port, + input_type = "number", + input_hint = _("Port number (default is 8080)"), + buttons = { + { + { + text = _("Cancel"), + id = "close", + callback = function() + UIManager:close(port_dialog) + end, + }, + { + text = _("OK"), + -- keep_menu_open = true, + callback = function() + local port = port_dialog:getInputValue() + logger.warn("port", port) + if port and port >= 1 and port <= 65535 then + self.port = port + G_reader_settings:saveSetting("httpinspector_port", port) + if self:isRunning() then + self:stop() + self:start() + end + end + UIManager:close(port_dialog) + touchmenu_instance:updateItems() + end, + }, + }, + }, + } + UIManager:show(port_dialog) + port_dialog:onShowKeyboard() + end, + } + }, + } +end + +local HTTP_RESPONSE_CODE = { + [200] = 'OK', + [201] = 'Created', + [202] = 'Accepted', + [204] = 'No Content', + [301] = 'Moved Permanently', + [302] = 'Found', + [304] = 'Not Modified', + [400] = 'Bad Request', + [401] = 'Unauthorized', + [403] = 'Forbidden', + [404] = 'Not Found', + [405] = 'Method Not Allowed', + [406] = 'Not Acceptable', + [408] = 'Request Timeout', + [410] = 'Gone', + [500] = 'Internal Server Error', + [501] = 'Not Implemented', + [503] = 'Service Unavailable', +} + +local CTYPE = { + CSS = "text/css", + HTML = "text/html", + JS = "application/javascript", + JSON = "application/json", + PNG = "image/png", + TEXT = "text/plain", +} + +function HttpInspector:sendResponse(reqinfo, http_code, content_type, body) + if not http_code then http_code = 400 end + if not body then body = "" end + if type(body) ~= "string" then body = tostring(body) end + + local response = {} + -- StreamMessageQueueServer:send() closes the connection, so announce + -- that with HTTP/1.0 and a "Connection: close" header. + table.insert(response, T("HTTP/1.0 %1 %2", http_code, HTTP_RESPONSE_CODE[http_code] or "Unspecified")) + -- If no content type provided, let the browser sniff it + if content_type then + -- Advertize all our text as being UTF-8 + local charset = "" + if util.stringStartsWith(content_type, "text/") then + charset = "; charset=utf-8" + end + table.insert(response, T("Content-Type: %1%2", content_type, charset)) + end + if http_code == 302 then + table.insert(response, T("Location: %1", body)) + body = "" + end + table.insert(response, T("Content-Length: %1", #body)) + table.insert(response, "Connection: close") + table.insert(response, "") + table.insert(response, body) + response = table.concat(response, "\r\n") + logger.dbg("HttpInspector: Sending response: " .. response:sub(1, 200)) + if self.http_socket then -- in case the plugin is gone... + self.http_socket:send(response, reqinfo.request_id) + end +end + +-- Process a uri, stepping one fragment (consider ? / = as separators) +local stepUriFragment = function(uri) + local ftype, fragment, remain = uri:match("^([/=?]*)([^/=?]+)(.*)$") + if ftype then + return ftype, fragment, remain + end + -- it ends with a separator: return it + return uri, nil, nil +end + +-- Parse multiple variables from uri, guessing their Lua type +-- ie with uri: nil/true/false/"true"/-1.2/"/"/abc/""/'d"/ef'/ +-- Nb args: 9 +-- 1: nil: nil +-- 2: boolean: true +-- 3: boolean: false +-- 4: string: true +-- 5: number: -1.2 +-- 6: string: / +-- 7: string: abc +-- 8: string: +-- 9: string: d"/ef +local getVariablesFromUri = function(uri) + local vars = {} + local nb_vars = 0 + if not uri then + return vars, nb_vars + end + local stop_char + local var_start_idx + local var_end_idx + local end_idx = #uri + local quoted + for i = 1, end_idx do + local c = uri:sub(i,i) + local skip = false + if not stop_char then + if c == "'" or c == '"' then + stop_char = c + var_start_idx = i + 1 + quoted = true + skip = true + elseif c == "/" then + skip = true + else + stop_char = "/" + var_start_idx = i + quoted = false + end + end + if not skip then + if c == stop_char or i == end_idx then + var_end_idx = c == stop_char and i-1 or i + local text = uri:sub(var_start_idx, var_end_idx) + -- (We properly get an empty string if var_end_idx= firstline then + if not lines then + lines = {} + end + table.insert(lines, line) + end + if num >= lastline then + break + end + num = num + 1 + end + f:close() + end + end + info = { + source = path, + firstline = firstline, + lastline = lastline, + } + if lines then + local signature = util.trim(lines[1]) + info.signature = signature + -- Try to guess (possibly wrongly) a few info from the signature string + local dummy, cnt + dummy, cnt = signature:gsub("%(%)","") -- check for "()", no arg + if cnt > 0 then + info.nb_args = 0 + else + dummy, cnt = signature:gsub(",","") -- check for nb of commas + info.nb_args = cnt and cnt + 1 or 1 + end + dummy, cnt = signature:gsub("%.%.%.","") -- check for "...", varargs + if cnt > 0 then + info.nb_args = -1 + end + dummy, cnt = signature:gsub("^[^(]*:","") + info.is_method = cnt > 0 + info.classname = signature:gsub(".-(%w+):.*","%1") + end + _function_info_cache[hash] = info + if not full_code then + return info + end + info = util.tableDeepCopy(info) + info.lines = lines + return info +end + +-- Guess class name of an object +local guessClassName = function(obj) + -- Look for some common methods we could infer a class from (add more as needed) + local classic_method_names = { + "init", + "new", + "getSize", + "paintTo", + "onReadSettings", + "onResume", + "onSuspend", + "onMenuHold", + "beforeSuspend", + "initNetworkManager", + "free", + "clear", + } + -- For an instance, we won't probably find them in the table itself, so we'll have to look + -- into its first metatable + local meta_table = getmetatable(obj) + local test_method, meta_test_method + for _, method_name in ipairs(classic_method_names) do + test_method = rawget(obj, method_name) + if test_method then + break + end + if meta_table and not meta_test_method then + meta_test_method = rawget(meta_table, method_name) + end + end + if not test_method then + test_method = meta_test_method + end + if test_method then + local func_info = getFunctionInfo(test_method) + return func_info.classname + end +end + +-- Nothing below is made available to translators: we output technical details +-- in HTML, for power users and developers, who should be fine with english. +local HOME_CONTENT = [[ + +KOReader inspector + +
+Welcome to KOReader inspector HTTP server!
+
+This service is aimed at developers, use at your own risk.
+
+Browse core objects:
+
  • ui the current application (ReaderUI or FileManager). +
  • device the Device object (get a screenshot: device/screen/bb). +
  • UIManager and its window stack. +
  • g_settings your global settings saved as settings.reader.lua. + +Send an event: +
  • list of dispatcher/gestures actions. +
  • (or broadcast an event if you know what you are doing.) +
  • + + +]] +-- Other ideas for entry points: +-- - Browse filesystem, koreader and library, allow upload of books +-- - Stream live crash.log + +-- Process HTTP request +function HttpInspector:onRequest(data, request_id) + -- Keep track of request info so nested calls can send the response + local reqinfo = { + request_id = request_id, + fragments = {}, + } + local method, uri = data:match("^(%u+) ([^\n]*) HTTP/%d%.%d\r?\n.*") + -- We only need to support GET, with our special simple URI syntax/grammar + if method ~= "GET" then + return self:sendResponse(reqinfo, 405, CTYPE.TEXT, "Only GET supported") + end + reqinfo.uri = uri + -- Decode any %-encoded stuff (should be ok to do it that early) + uri = util.urlDecode(uri) + logger.dbg("HttpInspector: Received request:", method, uri) + + if not util.stringStartsWith(uri, "/koreader/") then + -- Anything else is static content. + -- We allow the user to put anything he'd like to in /koreder/web/ and have + -- this content served as the main content, which can allow building a web + -- app with HTML/CSS/JS to interact with the API exposed under /koreader/. + if uri == "/" then + uri = "/index.html" + end + -- No security/sanity check for now + local filepath = DataStorage:getDataDir() .. "/web" .. uri + if uri == "/favicon.ico" then -- hijack this one to return our icon + filepath = "resources/koreader.png" + end + local f = io.open(filepath, "rb") + if f then + data = f:read("*all") + f:close() + return self:sendResponse(reqinfo, 200, nil, data) -- let content-type be sniffed + end + if uri == "/index.html" then + -- / but no /web/index.html created by the user: redirect to our /koreader/ + return self:sendResponse(reqinfo, 302, nil, "/koreader/") + end + return self:sendResponse(reqinfo, 404, CTYPE.TEXT, "Static file not found: koreader/web" .. uri) + end + + -- Request starts with /koreader/, followed by some predefined entry point + local ftype, fragment + ftype, fragment, uri = stepUriFragment(uri) -- skip "/koreader" + reqinfo.parsed_uri = ftype .. fragment + table.insert(reqinfo.fragments, 1, fragment) + + ftype, fragment, uri = stepUriFragment(uri) + if not fragment then + return self:sendResponse(reqinfo, 200, CTYPE.HTML, HOME_CONTENT) + -- return self:sendResponse(reqinfo, 400, CTYPE.TEXT, "Missing entry point.") + end + reqinfo.prev_parsed_uri = reqinfo.parsed_uri + reqinfo.parsed_uri = reqinfo.parsed_uri .. ftype .. fragment + table.insert(reqinfo.fragments, 1, fragment) + + -- We allow browsing a few of our core objects + if fragment == "ui" then + return self:exposeObject(self.ui, uri, reqinfo) + elseif fragment == "device" then + return self:exposeObject(Device, uri, reqinfo) + elseif fragment == "g_settings" then + return self:exposeObject(G_reader_settings, uri, reqinfo) + elseif fragment == "UIManager" then + return self:exposeObject(UIManager, uri, reqinfo) + elseif fragment == "event" then + return self:exposeEvent(uri, reqinfo) + elseif fragment == "broadcast" then + return self:exposeBroadcastEvent(uri, reqinfo) + end + + return self:sendResponse(reqinfo, 404, CTYPE.TEXT, "Unknown entry point.") +end + +-- Navigate object and its children according to uri, reach the +-- final object and act depending on its type and what's requested +function HttpInspector:exposeObject(obj, uri, reqinfo) + local ftype, fragment + local parent = obj + local current_key + while true do -- process URI + local obj_type = type(obj) + if ftype and fragment then + reqinfo.prev_parsed_uri = reqinfo.parsed_uri + reqinfo.parsed_uri = reqinfo.parsed_uri .. ftype .. fragment + table.insert(reqinfo.fragments, 1, fragment) + end + ftype, fragment, uri = stepUriFragment(uri) + + if obj_type == "table" then + if ftype == "/" then + if not fragment then + -- URI ends with 'object/': send a HTML page describing all this object's key/values + return self:browseObject(obj, reqinfo) + else + -- URI continues with 'object/key' + parent = obj + local as_number = tonumber(fragment) + fragment = as_number or fragment + current_key = fragment + obj = obj[fragment] + if obj == nil then + return self:sendResponse(reqinfo, 404, CTYPE.TEXT, "No such table/object key: "..fragment) + end + -- continue loop to process this children of our object + end + elseif ftype == "" then + -- URI ends with 'object' (without a trailing /): output it as JSON if possible + local ok, json = pcall(getAsJsonString, obj) + if ok then + return self:sendResponse(reqinfo, 200, CTYPE.JSON, json) + else + -- Probably nested/recursive data structures (ie. a widget with self.dialog pointing to a parent) + return self:sendResponse(reqinfo, 500, CTYPE.TEXT, json) + end + else + return self:sendResponse(reqinfo, 400, CTYPE.TEXT, "Invalid request: unexepected token after "..reqinfo.parsed_uri) + end + + elseif obj_type == "function" then + if ftype == "?" and not fragment then + -- URI ends with 'function?' : output some documentation about that function + return self:showFunctionDetails(obj, reqinfo) + elseif ftype == "/" or ftype == "?/" then + -- URI ends or continues with 'function/': call function, output return values as JSON + -- If 'function?/': do the same but output HTML, helpful for debugging + if fragment and uri then -- put back first argument into uri + uri = fragment .. uri + end + return self:callFunction(obj, parent, uri, ftype == "?/", reqinfo) + else + -- Nothing else accepted + return self:sendResponse(reqinfo, 400, CTYPE.TEXT, "Invalid request on function: use a trailing / to call, or ? to get details") + end + + elseif obj_type == "cdata" or obj_type == "userdata" or obj_type == "thread" then + -- We can't do much on these Lua types. + -- But try to guess if it's a BlitBuffer, that we can render as PNG ! + local ok, is_bb = pcall(function() return obj.writePNG ~= nil end) + if ok and is_bb then + local tmpfile = DataStorage:getDataDir() .. "/cache/tmp_bb.png" + ok = pcall(obj.writePNG, obj, tmpfile) + if ok then + local f = io.open(tmpfile, "rb") + if f then + local data = f:read("*all") + f:close() + os.remove(tmpfile) + return self:sendResponse(reqinfo, 200, CTYPE.PNG, data) + end + end + end + return self:sendResponse(reqinfo, 403, CTYPE.TEXT, "Can't act on object of type: "..obj_type) + + else + -- Simple Lua types: string, number, boolean, nil + if ftype == "" then + -- Return it as text + return self:sendResponse(reqinfo, 200, CTYPE.TEXT, tostring(obj)) + elseif (ftype == "=" or ftype == "?=") and fragment and uri then + -- 'property=value': assign value to property + -- 'property?=value': same, but output HTML allowing to get back to the parent + uri = fragment .. uri -- put back first frament into uri + local args, nb_args = getVariablesFromUri(uri) + if nb_args ~= 1 then + return self:sendResponse(reqinfo, 400, CTYPE.TEXT, "Variable assignment needs a single value") + end + local value = args[1] + parent[current_key] = value -- do what is asked: assign it + if ftype == "=" then + return self:sendResponse(reqinfo, 200, CTYPE.TEXT, T("Variable '%1' assigned with: %2", reqinfo.parsed_uri, tostring(value))) + else + value = tostring(value) + local html = {} + local add_html = function(h) table.insert(html, h) end + local html_quoted_value = value:gsub("&", "&"):gsub(">", ">"):gsub("<", "<") + add_html(T("%1.%2=%3", reqinfo.fragments[2], reqinfo.fragments[1], html_quoted_value)) + add_html(T("
    Variable '%1' assigned with: %2", reqinfo.parsed_uri, value))
    +                    add_html("")
    +                    add_html(T("Browse back to container object.", reqinfo.prev_parsed_uri))
    +                    html = table.concat(html, "\n")
    +                    return self:sendResponse(reqinfo, 200, CTYPE.HTML, html)
    +                end
    +            elseif ftype == "?" then
    +                return self:sendResponse(reqinfo, 400, CTYPE.TEXT, "No documentation available on simple types.")
    +            else
    +                -- Nothing else accepted
    +                return self:sendResponse(reqinfo, 400, CTYPE.TEXT, "Invalid request on variable")
    +            end
    +        end
    +    end
    +    return self:sendResponse(reqinfo, 400, CTYPE.TEXT, "Unexepected request") -- luacheck: ignore 511
    +end
    +
    +-- Send a HTML page describing all this object's key/values
    +function HttpInspector:browseObject(obj, reqinfo)
    +    local html = {}
    +    local add_html = function(h) table.insert(html, h) end
    +    -- We want to display keys sorted by value kind
    +    local KIND_OTHER    = 1 -- string/number/boolean/nil/cdata...
    +    local KIND_TABLE    = 2 -- table/object
    +    local KIND_FUNCTION = 3 -- function/method
    +    local KINDS = { KIND_OTHER, KIND_TABLE, KIND_FUNCTION }
    +    local html_by_obj_kind
    +    local reset_html_by_obj_kind = function() html_by_obj_kind = { {}, {}, {} } end
    +    local add_html_to_obj_kind = function(kind, h) table.insert(html_by_obj_kind[kind], h) end
    +
    +    local get_html_snippet = function(key, value, uri)
    +        local href = uri .. key
    +        local value_type = type(value)
    +        if value_type == "table" then
    +            local pad = ""
    +            local classinfo = guessClassName(value)
    +            if classinfo then
    +                pad = (" "):rep(32 - #(tostring(key)))
    +            end
    +            return T("J  %3 %4%5", href, href, key, pad, classinfo or ""), KIND_TABLE
    +        elseif value_type == "function" then
    +            local pad = (" "):rep(30 - #key)
    +            local func_info = getFunctionInfo(value)
    +            local siginfo = (func_info.is_method and "M" or "f") .. " " .. (func_info.nb_args >= 0 and func_info.nb_args or "*")
    +            return T("   %2() %3%4 %5", href, key, pad, siginfo, func_info.signature), KIND_FUNCTION
    +        elseif value_type == "string" or value_type == "number" or value_type == "boolean" or value_type == "nil" then
    +            -- This is not totally fullproof (\n will be eaten by Javascript prompt(), other stuff may fail or get corrupted),
    +            -- but it should be ok for simple strings.
    +            local quoted_value
    +            local html_value
    +            if value_type == "string" then
    +                quoted_value = '\\"' .. value:gsub('\\', '\\\\'):gsub('"', '"'):gsub("'", "'"):gsub('\n', '\\n'):gsub('<', '<'):gsub('>', '>') .. '\\"'
    +                html_value = value:gsub("&", "&"):gsub('"', """):gsub(">", ">"):gsub("<", "<")
    +                if html_value:match("\n") then
    +                    -- Newline in string: make it stand out
    +                    html_value = T("%1", html_value)
    +                end
    +            else
    +                quoted_value = tostring(value)
    +                html_value = tostring(value)
    +            end
    +            local ondblclick = T([[ondblclick='(function(){
    +                    var t=prompt("Update value of property: %1", "%2");
    +                    if (t!=null) {document.location.href="%3?="+t}
    +                    else {return false;}
    +                  })(); return false;']], key, quoted_value, href)
    +            return T("   %1: %3", key, ondblclick, html_value), KIND_OTHER
    +        else
    +            if value_type == "cdata" then
    +                local ok, is_bb = pcall(function() return value.writePNG ~= nil end)
    +                if ok and is_bb then
    +                    return T("   %2  BlitBuffer %3bpp %4x%5", href, key, value.getBpp(), value.w, value.h), KIND_OTHER
    +                end
    +            end
    +            return T("   %1: %2", key, value_type), KIND_OTHER
    +        end
    +    end
    +    -- add_html("")
    +    -- A little header may help noticing the page is updated (the browser url bar
    +    -- just above is usually updates before the page is loaded)
    +    add_html(T("%1", reqinfo.parsed_uri))
    +    add_html(T("
    %1/", reqinfo.parsed_uri))
    +    local classinfo = guessClassName(obj)
    +    if classinfo then
    +        add_html(T("  %1 instance", classinfo))
    +    end
    +    -- Keep track of names seen, so we can show these same names
    +    -- in super classes lighter, as they are then overriden.
    +    local seen_names = {}
    +    local seen_prefix = ""
    +    local seen_suffix = ""
    +    local prelude = ""
    +    while obj do
    +        local has_items = false
    +        reset_html_by_obj_kind()
    +        for key, value in ffiUtil.orderedPairs(obj) do
    +            local ignore = key == "__index"
    +            if not ignore then
    +                local snippet, kind = get_html_snippet(key, value, reqinfo.uri)
    +                if seen_names[key] then
    +                    add_html_to_obj_kind(kind, prelude .. seen_prefix .. snippet .. seen_suffix)
    +                else
    +                    add_html_to_obj_kind(kind, prelude .. snippet)
    +                end
    +                seen_names[key] = true
    +                prelude = ""
    +                has_items = true
    +            end
    +        end
    +        for _, kind in ipairs(KINDS) do
    +            for _, htm in ipairs(html_by_obj_kind[kind]) do
    +                add_html(htm)
    +            end
    +        end
    +        if not has_items then
    +            add_html("(empty table/object)")
    +        end
    +        obj = getmetatable(obj)
    +        if obj then
    +            prelude = "
    " + classinfo = guessClassName(obj) + if classinfo then + add_html(prelude .. T(" %1", classinfo)) + prelude = "" + end + end + end + add_html("
    ") + html = table.concat(html, "\n") + return self:sendResponse(reqinfo, 200, CTYPE.HTML, html) +end + +-- Send a HTML page describing a function or method +function HttpInspector:showFunctionDetails(obj, reqinfo) + local html = {} + local add_html = function(h) table.insert(html, h) end + local base_uri = reqinfo.parsed_uri + local func_info = getFunctionInfo(obj, true) + add_html(T("%1?", reqinfo.fragments[1])) + add_html(T("
    %1", reqinfo.parsed_uri))
    +    add_html(T("  %1", func_info.signature))
    +    add_html("")
    +    add_html(T("This is a %1, accepting or requiring up to %2 arguments.", (func_info.is_method and "method" or "function"), func_info.nb_args >= 0 and func_info.nb_args or "many"))
    +    add_html("")
    +    add_html("We can't tell you more, neither what type of arguments it expects, and what it will do (it may crash or let KOReader in an unusable state).")
    +    add_html("Only values of simple type (string, number, boolean, nil) can be provided as arguments and returned as results. Functions expecting tables or objects will most probably fail. Call at your own risk!")
    +    add_html("")
    +    local output_sample_uris = function(token)
    +        local some_uri = base_uri .. token
    +        local pad = (" "):rep(#base_uri + 25 - #some_uri)
    +        add_html(T("%2 %3 without args", some_uri, some_uri, pad))
    +        local nb_args = func_info.nb_args >= 0 and func_info.nb_args or 4 -- limit to 4 if varargs
    +        for i=1, nb_args do
    +            if i > 1 then
    +                some_uri = some_uri .. "/"
    +            end
    +            some_uri = some_uri .. "arg" .. tostring(i)
    +            pad = (" "):rep(#base_uri + 25 - #some_uri)
    +            add_html(T("%1 %2 with %3 args", some_uri, pad, i))
    +        end
    +    end
    +    add_html("It may be called, to get results as HTML, with:")
    +    output_sample_uris("?/")
    +    add_html("")
    +    add_html("It may be called, to get results as JSON, with:")
    +    output_sample_uris("/")
    +    add_html("")
    +    local dummy, git_commit = require("version"):getNormalizedCurrentVersion()
    +    local github_uri = T("https://github.com/koreader/koreader/blob/%1/%2#L%3", git_commit, func_info.source, func_info.firstline)
    +    add_html(T("Here's a snippet of the function code (it can be viewed with syntax coloring and line numbers on Github):", github_uri))
    +    add_html("
    ") + for _, line in ipairs(func_info.lines) do + add_html(line) + end + add_html("\n
    ") + add_html("
    ") + html = table.concat(html, "\n") + return self:sendResponse(reqinfo, 200, CTYPE.HTML, html) +end + +-- Call a function or method, send results as JSON or HTML +function HttpInspector:callFunction(func, instance, args_as_uri, output_html, reqinfo) + local html = {} + local add_html = function(h) table.insert(html, h) end + local args, nb_args = getVariablesFromUri(args_as_uri) + local func_info = getFunctionInfo(func) + if output_html then + add_html(T("%1(%2)", reqinfo.fragments[1], args_as_uri or "")) + add_html(T("
    %1 (%2)", reqinfo.parsed_uri, args_as_uri or ""))
    +        add_html(T("  %1", func_info.signature))
    +        add_html("")
    +        add_html(T("Nb args: %1", nb_args))
    +        for i=1, nb_args do
    +            local arg = args[i]
    +            add_html(T("  %1: %2: %3", i, type(arg), tostring(arg)))
    +        end
    +        add_html("")
    +    end
    +    local res, nbr, http_code, json, ok, ok2, err, trace
    +    if func_info.is_method then
    +        res = table.pack(xpcall(func, debug.traceback, instance, unpack(args, 1, nb_args)))
    +    else
    +        res = table.pack(xpcall(func, debug.traceback, unpack(args, 1, nb_args)))
    +    end
    +    ok = res[1]
    +    if ok then
    +        http_code = 200
    +        table.remove(res, 1) -- remove pcall's ok
    +        -- table.pack and JSON.encode may use this "n" key value to set the nb
    +        -- of element and guess it is an array. Keep it updated.
    +        nbr = res["n"]
    +        if nbr then
    +            nbr = nbr - 1
    +            res["n"] = nbr
    +            if nbr == 0 then
    +                res = nil
    +            end
    +        end
    +        if res == nil then
    +            -- getAsJsonString would return "null", let's return an empty array instead
    +            json = "[]"
    +        else
    +            ok2, json = pcall(getAsJsonString, res)
    +            if not ok2 then
    +                json = "[ 'can't be reprensented as json' ]"
    +            end
    +        end
    +    else
    +        http_code = 500
    +        -- On error, instead of the array on success, let's return an object,
    +        -- with keys 'error' and "stacktrace"
    +        err, trace = res[2]:match("^(.-)\n(.*)$")
    +        json = getAsJsonString({["error"] = err, ["stacktrace"] = trace})
    +    end
    +    if output_html then
    +        local bgcolor = ok and "#bbffbb" or "#ffbbbb"
    +        local status = ok and "Success" or "Failure"
    +        add_html(T("%2", bgcolor, status))
    +        if ok then
    +            add_html(T("Nb returned values: %1", nbr))
    +            for i=1, nbr do
    +                local r = res[i]
    +                add_html(T("  %1: %2: %3", i, type(r), tostring(r)))
    +            end
    +            add_html("")
    +            add_html("Returned values as JSON:")
    +            add_html(json)
    +        else
    +            add_html(err)
    +            add_html(trace)
    +        end
    +        add_html("
    ") + html = table.concat(html, "\n") + return self:sendResponse(reqinfo, http_code, CTYPE.HTML, html) + else + return self:sendResponse(reqinfo, http_code, CTYPE.JSON, json) + end +end + +-- Handy function for testing the above, to be called with: +-- /koreader/ui/httpinspector/someFunctionForInteractiveTesting?/ +function HttpInspector:someFunctionForInteractiveTesting(...) + if select(1, ...) then + HttpInspector.foo.bar = true -- error + end + return self and self.name or "no self", #(table.pack(...)), "original args follow", ... + -- Copy and append this as args to the url, to get an error: + -- /true/nil/true/false/"true"/-1.2/"/"/abc/'d"/ef'/ + -- and to get a success: + -- /false/nil/true/false/"true"/-1.2/"/"/abc/'d"/ef'/ +end + +local _dispatcher_actions + +local getOrderedDispatcherActions = function() + if _dispatcher_actions then + return _dispatcher_actions + end + local Dispatcher = require("dispatcher") + local settings, order + local n = 1 + while true do + local name, value = debug.getupvalue(Dispatcher.init, n) + if not name then break end + if name == "settingsList" then + settings = value + break + end + n = n + 1 + end + while true do + local name, value = debug.getupvalue(Dispatcher.registerAction, n) + if not name then break end + if name == "dispatcher_menu_order" then + order = value + break + end + n = n + 1 + end + -- Copied and pasted from Dispatcher (we can't reach that the same way as above) + local section_list = { + {"general", _("General")}, + {"device", _("Device")}, + {"screen", _("Screen and lights")}, + {"filemanager", _("File browser")}, + {"reader", _("Reader")}, + {"rolling", _("Reflowable documents (epub, fb2, txt…)")}, + {"paging", _("Fixed layout documents (pdf, djvu, pics…)")}, + } + _dispatcher_actions = {} + for _, section in ipairs(section_list) do + table.insert(_dispatcher_actions, section[2]) + local section_key = section[1] + for _, k in ipairs(order) do + if settings[k][section_key] == true then + local t = util.tableDeepCopy(settings[k]) + t.dispatcher_id = k + table.insert(_dispatcher_actions, t) + end + end + end + -- Add a useful one + table.insert(_dispatcher_actions, 2, { general=true, separator=true, event="Close", category="none", title="Close top most widget"}) + return _dispatcher_actions +end + +function HttpInspector:exposeEvent(uri, reqinfo) + local ftype, fragment -- luacheck: no unused + ftype, fragment, uri = stepUriFragment(uri) -- luacheck: no unused + if fragment then + -- Event name and args provided. + -- We may get multiple events, separated by a dummy arg /&/ + local events = {} + local ev_names = {fragment} + local cur_ev_args = {fragment} + local args, nb_args = getVariablesFromUri(uri) + for i=1, nb_args do + local arg = args[i] + if arg ~= "&" then + if #cur_ev_args == 0 then + table.insert(ev_names, arg) + end + table.insert(cur_ev_args, arg) + else + table.insert(events, Event:new(table.unpack(cur_ev_args))) + cur_ev_args = {} + end + end + if #cur_ev_args > 0 then + table.insert(events, Event:new(table.unpack(cur_ev_args))) + end + -- As events may switch/reload the document, or exit/restart KOReader, + -- we delay them a bit so we can send the HTTP response and properly + -- shutdown the HTTP server + UIManager:nextTick(function() + for _, ev in ipairs(events) do + UIManager:sendEvent(ev) + end + end) + return self:sendResponse(reqinfo, 200, CTYPE.TEXT, T("Event sent: %1", table.concat(ev_names, ", "))) + end + + -- No event provided. + -- We want to show the list of actions exposed by Dispatcher (that are all handled as Events). + local actions = getOrderedDispatcherActions() + -- if true then return self:sendResponse(reqinfo, 200, CTYPE.JSON, getAsJsonString(actions)) end + local html = {} + local add_html = function(h) table.insert(html, h) end + add_html(T("High-level KOReader events")) + add_html(T("
    List of high-level KOReader events\n(all those available as actions for gestures and profiles)"))
    +    for _, action in ipairs(actions) do
    +        if type(action) == "string" then
    +            add_html(T("
    %1", action)) + elseif action.condition == false then + -- Some bottom menu are just disabled on all devices, + -- so just don't show any disabled action + do end -- luacheck: ignore 541 + else + local active = false + if action.general or action.device or action.screen then + active = true + elseif action.reader and self.ui.view then + active = true + elseif action.rolling and self.ui.rolling then + active = true + elseif action.paging and self.ui.paging then + active = true + elseif action.filemanager and self.ui.onSwipeFM then + active = true + end + + local title = action.title + if not active then + title = T("%1 (no effect on current application/document)", title) + end + add_html(T("%1", title)) + + -- Same messy logic as in Dispatcher:execute() (not everything has been tested). + local get_base_href = function() + return reqinfo.parsed_uri .. (action.event and "/"..action.event or "") + end + if action.configurable then + -- Such actions sends a first (possibly single with KOpt settings) event + -- to update the setting value for the bottom menu + -- We'll have to insert it in our single URL which may then carry 2 events + get_base_href = function(v, is_indice, single) + return T("%1/%2/%3/%4%5", reqinfo.parsed_uri, "ConfigChange", action.configurable.name, is_indice and action.configurable.values[v] or v, single and "" or (action.event and "/&/"..action.event or "")) + end + end + + if action.category == "none" then + -- Shouldn't have any 'configurable' + local href + if action.arg ~= nil then + href = T("%1/%2", get_base_href(), tostring(action.arg)) + else + href = get_base_href() + end + add_html(T(" %1", href)) + elseif action.category == "string" then + -- Multiple values, can have a 'configurable' + local args, toggle + if not action.args and action.args_func then + args, toggle = action.args_func() + else + args, toggle = action.args, action.toggle + end + if type(args[1]) == "table" then + add_html(T(" %1/... unsupported (table arguments)", get_base_href("..."))) + else + for i=1, #args do + local href = T("%1/%2", get_base_href(i, true), tostring(args[i])) + local unit = action.unit and " "..action.unit or "" + local default = args[i] == action.default and " (default)" or "" + add_html(T(" %1 \t%2%3%4", href, toggle[i], unit, default)) + end + end + elseif action.category == "absolutenumber" then + local suggestions = {} + if action.configurable and action.configurable.values then + for num, val in ipairs(action.configurable.values) do + local unit = action.unit and " "..action.unit or "" + local default = val == action.default and " (default)" or "" + table.insert(suggestions, { val, T("%1%2%3", val, unit, default) }) + end + else + local min, max = action.min, action.max + if min == -1 and max > 1 then + table.insert(suggestions, { min, "off / none" }) + min = 0 + end + table.insert(suggestions, { min, "min" }) + -- Add interesting values for specific actions + if action.dispatcher_id == "page_jmp" then + table.insert(suggestions, { -1, "-1 page" }) + end + table.insert(suggestions, { (min + max)/2, "" }) + if action.dispatcher_id == "page_jmp" then + table.insert(suggestions, { 1, "+1 page" }) + end + table.insert(suggestions, { max, "max" }) + end + for _, suggestion in ipairs(suggestions) do + local href = T("%1/%2", get_base_href(suggestion[1]), tostring(suggestion[1])) + add_html(T(" %1 \t%2", href, suggestion[2])) + end + elseif action.category == "incrementalnumber" then + -- Shouldn't have any 'configurable' + local suggestions = {} + local min, max = action.min, action.max + table.insert(suggestions, { min, "min" }) + if action.step then + for i=1, 5 do + min = min + action.step + table.insert(suggestions, { min, "" }) + end + else + table.insert(suggestions, { (min + max)/2, "" }) + end + table.insert(suggestions, { max, "max" }) + for _, suggestion in ipairs(suggestions) do + local href = T("%1/%2", get_base_href(suggestion[1]), tostring(suggestion[1])) + add_html(T(" %1 \t%2", href, suggestion[2])) + end + elseif action.category == "arg" then + add_html(T(" %1/... unsupported (gesture arguments)", get_base_href("..."))) + elseif action.category == "configurable" then + -- No other action event to send + for i=1, #action.configurable.values do + local href = T("%1", get_base_href(i, true)) + add_html(T(" %1 \t%2", href, action.toggle[i])) + end + else + -- Should not happen + add_html(T(" %1/... not implemented", get_base_href("..."))) + add_html(getAsJsonString(action)) + end + if action.separator then + add_html("") + end + end + end + add_html("
    ") + html = table.concat(html, "\n") + return self:sendResponse(reqinfo, 200, CTYPE.HTML, html) +end + +function HttpInspector:exposeBroadcastEvent(uri, reqinfo) + -- Similar to previous one, without any list. + local ftype, fragment -- luacheck: no unused + ftype, fragment, uri = stepUriFragment(uri) -- luacheck: no unused + if fragment then + -- Event name and args provided. + -- We may get multiple events, separated by a dummy arg /&/ + local events = {} + local ev_names = {fragment} + local cur_ev_args = {fragment} + local args, nb_args = getVariablesFromUri(uri) + for i=1, nb_args do + local arg = args[i] + if arg ~= "&" then + if #cur_ev_args == 0 then + table.insert(ev_names, arg) + end + table.insert(cur_ev_args, arg) + else + table.insert(events, Event:new(table.unpack(cur_ev_args))) + cur_ev_args = {} + end + end + if #cur_ev_args > 0 then + table.insert(events, Event:new(table.unpack(cur_ev_args))) + end + -- As events may switch/reload the document, or exit/restart KOReader, + -- we delay them a bit so we can send the HTTP response and properly + -- shutdown the HTTP server + UIManager:nextTick(function() + for _, ev in ipairs(events) do + UIManager:broadcastEvent(ev) + end + end) + return self:sendResponse(reqinfo, 200, CTYPE.TEXT, T("Event broadcasted: %1", table.concat(ev_names, ", "))) + end + + -- No event provided. + local html = {} + local add_html = function(h) table.insert(html, h) end + add_html(T("Broadcast event")) + add_html(T("
    No suggestion, use at your own risk."))
    +    add_html(T("Usage: %1", "/koreader/broadcast/EventName/arg1/arg2"))
    +    add_html("
    ") + html = table.concat(html, "\n") + return self:sendResponse(reqinfo, 200, CTYPE.HTML, html) +end + +return HttpInspector