From dd1c538b2ed4e025be6a4006e0e8e2a2bf37ad18 Mon Sep 17 00:00:00 2001 From: Jo-Philipp Wich Date: Tue, 25 Oct 2022 00:55:14 +0200 Subject: [PATCH] luci-app-commands: rewrite to client side rendering Rewrite the luci-app-command configuration to client side cbi forms and port the server side templates and controller logic to ucode. Also utilize a query string parameter to pass custom arguments. Fixes: #5559 Signed-off-by: Jo-Philipp Wich --- .../luci-static/resources/view/commands.js | 34 +++ .../luasrc/controller/commands.lua | 268 ------------------ .../luasrc/model/cbi/commands.lua | 27 -- .../luasrc/view/commands.htm | 187 ------------ .../luasrc/view/commands_public.htm | 50 ---- .../share/luci/menu.d/luci-app-commands.json | 56 ++++ .../ucode/controller/commands.uc | 256 +++++++++++++++++ .../ucode/template/commands.ut | 179 ++++++++++++ .../ucode/template/commands_public.ut | 48 ++++ 9 files changed, 573 insertions(+), 532 deletions(-) create mode 100644 applications/luci-app-commands/htdocs/luci-static/resources/view/commands.js delete mode 100644 applications/luci-app-commands/luasrc/controller/commands.lua delete mode 100644 applications/luci-app-commands/luasrc/model/cbi/commands.lua delete mode 100644 applications/luci-app-commands/luasrc/view/commands.htm delete mode 100644 applications/luci-app-commands/luasrc/view/commands_public.htm create mode 100644 applications/luci-app-commands/root/usr/share/luci/menu.d/luci-app-commands.json create mode 100644 applications/luci-app-commands/ucode/controller/commands.uc create mode 100644 applications/luci-app-commands/ucode/template/commands.ut create mode 100644 applications/luci-app-commands/ucode/template/commands_public.ut diff --git a/applications/luci-app-commands/htdocs/luci-static/resources/view/commands.js b/applications/luci-app-commands/htdocs/luci-static/resources/view/commands.js new file mode 100644 index 0000000000..6d369733c6 --- /dev/null +++ b/applications/luci-app-commands/htdocs/luci-static/resources/view/commands.js @@ -0,0 +1,34 @@ +'use strict'; + +'require view'; +'require form'; + +return view.extend({ + render: function(data) { + var m, s, o; + + m = new form.Map('luci', _('Custom Commands'), + _('This page allows you to configure custom shell commands which can be easily invoked from the web interface.')); + + s = m.section(form.GridSection, 'command'); + s.nodescriptions = true; + s.anonymous = true; + s.addremove = true; + + o = s.option(form.Value, 'name', _('Description'), + _('A short textual description of the configured command')); + + o = s.option(form.Value, 'command', _('Command'), _('Command line to execute')); + o.textvalue = function(section_id) { + return E('code', [ this.cfgvalue(section_id) ]); + }; + + o = s.option(form.Flag, 'param', _('Custom arguments'), + _('Allow the user to provide additional command line arguments')); + + o = s.option(form.Flag, 'public', _('Public access'), + _('Allow executing the command and downloading its output without prior authentication')); + + return m.render(); + } +}); diff --git a/applications/luci-app-commands/luasrc/controller/commands.lua b/applications/luci-app-commands/luasrc/controller/commands.lua deleted file mode 100644 index f6227c6e4e..0000000000 --- a/applications/luci-app-commands/luasrc/controller/commands.lua +++ /dev/null @@ -1,268 +0,0 @@ --- Copyright 2012 Jo-Philipp Wich --- Licensed to the public under the Apache License 2.0. - -module("luci.controller.commands", package.seeall) - -function index() - entry({"admin", "system", "commands"}, firstchild(), _("Custom Commands"), 80).acl_depends = { "luci-app-commands" } - entry({"admin", "system", "commands", "dashboard"}, template("commands"), _("Dashboard"), 1) - entry({"admin", "system", "commands", "config"}, cbi("commands"), _("Configure"), 2) - entry({"admin", "system", "commands", "run"}, call("action_run"), nil, 3).leaf = true - entry({"admin", "system", "commands", "download"}, call("action_download"), nil, 3).leaf = true - - entry({"command"}, call("action_public"), nil, 1).leaf = true -end - ---- Decode a given string into arguments following shell quoting rules ---- [[abc \def "foo\"bar" abc'def']] -> [[abc def]] [[foo"bar]] [[abcdef]] -local function parse_args(str) - local args = { } - - local function isspace(c) - if c == 9 or c == 10 or c == 11 or c == 12 or c == 13 or c == 32 then - return c - end - end - - local function isquote(c) - if c == 34 or c == 39 or c == 96 then - return c - end - end - - local function isescape(c) - if c == 92 then - return c - end - end - - local function ismeta(c) - if c == 36 or c == 92 or c == 96 then - return c - end - end - - --- Convert given table of byte values into a Lua string and append it to - --- the "args" table. Segment byte value sequence into chunks of 256 values - --- to not trip over the parameter limit for string.char() - local function putstr(bytes) - local chunks = { } - local csz = 256 - local upk = unpack - local chr = string.char - local min = math.min - local len = #bytes - local off - - for off = 1, len, csz do - chunks[#chunks+1] = chr(upk(bytes, off, min(off + csz - 1, len))) - end - - args[#args+1] = table.concat(chunks) - end - - --- Scan substring defined by the indexes [s, e] of the string "str", - --- perform unquoting and de-escaping on the fly and store the result in - --- a table of byte values which is passed to putstr() - local function unquote(s, e) - local off, esc, quote - local res = { } - - for off = s, e do - local byte = str:byte(off) - local q = isquote(byte) - local e = isescape(byte) - local m = ismeta(byte) - - if e then - esc = true - elseif esc then - if m then res[#res+1] = 92 end - res[#res+1] = byte - esc = false - elseif q and quote and q == quote then - quote = nil - elseif q and not quote then - quote = q - else - if m then res[#res+1] = 92 end - res[#res+1] = byte - end - end - - putstr(res) - end - - --- Find substring boundaries in "str". Ignore escaped or quoted - --- whitespace, pass found start- and end-index for each substring - --- to unquote() - local off, esc, start, quote - for off = 1, #str + 1 do - local byte = str:byte(off) - local q = isquote(byte) - local s = isspace(byte) or (off > #str) - local e = isescape(byte) - - if esc then - esc = false - elseif e then - esc = true - elseif q and quote and q == quote then - quote = nil - elseif q and not quote then - start = start or off - quote = q - elseif s and not quote then - if start then - unquote(start, off - 1) - start = nil - end - else - start = start or off - end - end - - --- If the "quote" is still set we encountered an unfinished string - if quote then - unquote(start, #str) - end - - return args -end - -local function parse_cmdline(cmdid, args) - local uci = require "luci.model.uci".cursor() - if uci:get("luci", cmdid) == "command" then - local cmd = uci:get_all("luci", cmdid) - local argv = parse_args(cmd.command) - local i, v - - if cmd.param == "1" and args then - for i, v in ipairs(parse_args(luci.http.urldecode(args))) do - argv[#argv+1] = v - end - end - - for i, v in ipairs(argv) do - if v:match("[^%w%.%-i/|]") then - argv[i] = '"%s"' % v:gsub('"', '\\"') - end - end - - return argv - end -end - -function execute_command(callback, ...) - local fs = require "nixio.fs" - local argv = parse_cmdline(...) - if argv then - local outfile = os.tmpname() - local errfile = os.tmpname() - - local rv = os.execute(table.concat(argv, " ") .. " >%s 2>%s" %{ outfile, errfile }) - local stdout = fs.readfile(outfile, 1024 * 512) or "" - local stderr = fs.readfile(errfile, 1024 * 512) or "" - - fs.unlink(outfile) - fs.unlink(errfile) - - local binary = not not (stdout:match("[%z\1-\8\14-\31]")) - - callback({ - ok = true, - command = table.concat(argv, " "), - stdout = not binary and stdout, - stderr = stderr, - exitcode = rv, - binary = binary - }) - else - callback({ - ok = false, - code = 404, - reason = "No such command" - }) - end -end - -function return_json(result) - if result.ok then - luci.http.prepare_content("application/json") - luci.http.write_json(result) - else - luci.http.status(result.code, result.reason) - end -end - -function action_run(...) - execute_command(return_json, ...) -end - -function return_html(result) - if result.ok then - require("luci.template") - luci.template.render("commands_public", { - exitcode = result.exitcode, - stdout = result.stdout, - stderr = result.stderr - }) - else - luci.http.status(result.code, result.reason) - end - -end - -function action_download(...) - local fs = require "nixio.fs" - local argv = parse_cmdline(...) - if argv then - local fd = io.popen(table.concat(argv, " ") .. " 2>/dev/null") - if fd then - local chunk = fd:read(4096) or "" - local name - if chunk:match("[%z\1-\8\14-\31]") then - luci.http.header("Content-Disposition", "attachment; filename=%s" - % fs.basename(argv[1]):gsub("%W+", ".") .. ".bin") - luci.http.prepare_content("application/octet-stream") - else - luci.http.header("Content-Disposition", "attachment; filename=%s" - % fs.basename(argv[1]):gsub("%W+", ".") .. ".txt") - luci.http.prepare_content("text/plain") - end - - while chunk do - luci.http.write(chunk) - chunk = fd:read(4096) - end - - fd:close() - else - luci.http.status(500, "Failed to execute command") - end - else - luci.http.status(404, "No such command") - end -end - - -function action_public(cmdid, args) - local disp = false - if string.sub(cmdid, -1) == "s" then - disp = true - cmdid = string.sub(cmdid, 1, -2) - end - local uci = require "luci.model.uci".cursor() - if cmdid and - uci:get("luci", cmdid) == "command" and - uci:get("luci", cmdid, "public") == "1" - then - if disp then - execute_command(return_html, cmdid, args) - else - action_download(cmdid, args) - end - else - luci.http.status(403, "Access to command denied") - end - end diff --git a/applications/luci-app-commands/luasrc/model/cbi/commands.lua b/applications/luci-app-commands/luasrc/model/cbi/commands.lua deleted file mode 100644 index 7794f15379..0000000000 --- a/applications/luci-app-commands/luasrc/model/cbi/commands.lua +++ /dev/null @@ -1,27 +0,0 @@ --- Copyright 2012 Jo-Philipp Wich --- Licensed to the public under the Apache License 2.0. - -local m, s - -m = Map("luci", translate("Custom Commands"), - translate("This page allows you to configure custom shell commands which can be easily invoked from the web interface.")) - -s = m:section(TypedSection, "command", "") -s.template = "cbi/tblsection" -s.anonymous = true -s.addremove = true - - -s:option(Value, "name", translate("Description"), - translate("A short textual description of the configured command")) - -s:option(Value, "command", translate("Command"), - translate("Command line to execute")) - -s:option(Flag, "param", translate("Custom arguments"), - translate("Allow the user to provide additional command line arguments")) - -s:option(Flag, "public", translate("Public access"), - translate("Allow executing the command and downloading its output without prior authentication")) - -return m diff --git a/applications/luci-app-commands/luasrc/view/commands.htm b/applications/luci-app-commands/luasrc/view/commands.htm deleted file mode 100644 index 634090e7d7..0000000000 --- a/applications/luci-app-commands/luasrc/view/commands.htm +++ /dev/null @@ -1,187 +0,0 @@ -<%# - Copyright 2012 Jo-Philipp Wich - Licensed to the public under the Apache License 2.0. --%> - -<% css = [[ - -.commandbox { - height: 12em; - width: 30%; - float: left; - height: 12em; - margin: 5px; - position: relative; -} - -.commandbox h3 { - font-size: 1.5em !important; - line-height: 2em !important; - margin: 0 !important; -} - -.commandbox input[type="text"] { - width: 50% !important; -} - -.commandbox div { - position: absolute; - left: 0; - bottom: 1.5em; -} - -]] -%> - -<%+header%> - - - -<% - local uci = require "luci.model.uci".cursor() - local commands = { } - - uci:foreach("luci", "command", function(s) commands[#commands+1] = s end) -%> - -
-
-

<%:Custom Commands%>

- <% if #commands == 0 then %> -
-
-
-

- <%:This section contains no values yet%> -

-
-
-
- <% else %> -
- <% local _, command; for _, command in ipairs(commands) do %> -
-

<%=pcdata(command.name)%>

-

<%:Command:%> <%=pcdata(command.command)%>

- <% if command.param == "1" then %> -

<%:Arguments:%>

- <% end %> -
- - - <% if command.public == "1" then %> - - <% end %> -
-
- <% end %> - -

- -
- <% end %> - -
- - -
- -<%+footer%> diff --git a/applications/luci-app-commands/luasrc/view/commands_public.htm b/applications/luci-app-commands/luasrc/view/commands_public.htm deleted file mode 100644 index f20799d40f..0000000000 --- a/applications/luci-app-commands/luasrc/view/commands_public.htm +++ /dev/null @@ -1,50 +0,0 @@ -<%# - Copyright 2016 t123yh - Licensed to the public under the Apache License 2.0. --%> - -<% css = [[ -.alert-success { - color: #3c763d; - background-color: #dff0d8; - border-color: #d6e9c6; -} - -.alert { - padding: 15px; - margin-bottom: 20px; - border: 1px solid transparent; - border-radius: 4px; -} - -.alert-warning { - color: #8a6d3b; - background-color: #fcf8e3; - border-color: #faebcc; -} -]] -%> - -<%+header%> - -<% if exitcode == 0 then %> - -<% else %> - -<% end %> - -<% if stdout ~= "" then %> -

<%:Standard Output%>

-
<%= stdout %>
-<% end %> - -<% if stderr ~= "" then %> -

<%:Standard Error%>

-
<%= stderr %>
-<% end %> - - - -<%+footer%> \ No newline at end of file diff --git a/applications/luci-app-commands/root/usr/share/luci/menu.d/luci-app-commands.json b/applications/luci-app-commands/root/usr/share/luci/menu.d/luci-app-commands.json new file mode 100644 index 0000000000..8230b14bc6 --- /dev/null +++ b/applications/luci-app-commands/root/usr/share/luci/menu.d/luci-app-commands.json @@ -0,0 +1,56 @@ +{ + "admin/system/commands": { + "title": "Custom Commands", + "order": 80, + "action": { + "type": "firstchild" + }, + "depends": { + "acl": [ "luci-app-commands" ] + } + }, + + "admin/system/commands/dashboard": { + "title": "Dashboard", + "order": 1, + "action": { + "type": "template", + "path": "commands" + } + }, + + "admin/system/commands/config": { + "title": "Configure", + "order": 2, + "action": { + "type": "view", + "path": "commands" + } + }, + + "admin/system/commands/run/*": { + "order": 3, + "action": { + "type": "function", + "module": "luci.controller.commands", + "function": "action_run" + } + }, + + "admin/system/commands/download/*": { + "order": 4, + "action": { + "type": "function", + "module": "luci.controller.commands", + "function": "action_download" + } + }, + + "command/*": { + "action": { + "type": "function", + "module": "luci.controller.commands", + "function": "action_public" + } + } +} diff --git a/applications/luci-app-commands/ucode/controller/commands.uc b/applications/luci-app-commands/ucode/controller/commands.uc new file mode 100644 index 0000000000..9126d59eb0 --- /dev/null +++ b/applications/luci-app-commands/ucode/controller/commands.uc @@ -0,0 +1,256 @@ +// Copyright 2012-2022 Jo-Philipp Wich +// Licensed to the public under the Apache License 2.0. + +'use strict'; + +import { basename, mkstemp, popen } from 'fs'; +import { urldecode } from 'luci.http'; + +// Decode a given string into arguments following shell quoting rules +// [[abc\ def "foo\"bar" abc'def']] -> [[abc def]] [[foo"bar]] [[abcdef]] +function parse_args(str) { + let args = []; + + function isspace(c) { + if (c == 9 || c == 10 || c == 11 || c == 12 || c == 13 || c == 32) + return c; + } + + function isquote(c) { + if (c == 34 || c == 39 || c == 96) + return c; + } + + function isescape(c) { + if (c == 92) + return c; + } + + function ismeta(c) { + if (c == 36 || c == 92 || c == 96) + return c; + } + + // Scan substring defined by the indexes [s, e] of the string "str", + // perform unquoting and de-escaping on the fly and store the result + function unquote(start, end) { + let esc, quote, res = []; + + for (let off = start; off < end; off++) { + const byte = ord(str, off); + const q = isquote(byte); + const e = isescape(byte); + const m = ismeta(byte); + + if (esc) { + if (!m) + push(res, 92); + + push(res, byte); + esc = false; + } + else if (e && quote != 39) { + esc = true; + } + else if (q && quote && q == quote) { + quote = null; + } + else if (q && !quote) { + quote = q; + } + else { + push(res, byte); + } + } + + push(args, chr(...res)); + } + + // Find substring boundaries in "str". Ignore escaped or quoted + // whitespace, pass found start- and end-index for each substring + // to unquote() + let esc, start, quote; + + for (let off = 0; off <= length(str); off++) { + const byte = ord(str, off); + const q = isquote(byte); + const s = isspace(byte) ?? (byte === null); + const e = isescape(byte); + + if (esc) { + esc = false; + } + else if (e && quote != 39) { + esc = true; + start ??= off; + } + else if (q && quote && q == quote) { + quote = null; + } + else if (q && !quote) { + start ??= off; + quote = q; + } + else if (s && !quote) { + if (start !== null) { + unquote(start, off); + start = null; + } + } + else { + start ??= off; + } + } + + // If the "quote" is still set we encountered an unfinished string + if (quote) + unquote(start, length(str)); + + return args; +} + +function test_binary(str) { + for (let off = 0, byte = ord(str); off < length(str); byte = ord(str, ++off)) + if (byte <= 8 || (byte >= 14 && byte <= 31)) + return true; + + return false; +} + +function parse_cmdline(cmdid, args) { + if (uci.get('luci', cmdid) == 'command') { + let cmd = uci.get_all('luci', cmdid); + let argv = parse_args(cmd?.command); + + if (cmd?.param == '1') { + if (length(args)) + push(argv, ...(parse_args(urldecode(args)) ?? [])); + else if (length(args = http.formvalue('args'))) + push(argv, ...(parse_args(args) ?? [])); + } + + return map(argv, v => match(v, /[^\w.\/|-]/) ? `'${replace(v, "'", "'\\''")}'` : v); + } +} + +function execute_command(callback, ...args) { + let argv = parse_cmdline(...args); + + if (argv) { + let outfd = mkstemp(); + let errfd = mkstemp(); + + const exitcode = system(`${join(' ', argv)} >&${outfd.fileno()} 2>&${errfd.fileno()}`); + + outfd.seek(0); + errfd.seek(0); + + const stdout = outfd.read(1024 * 512) ?? ''; + const stderr = errfd.read(1024 * 512) ?? ''; + + outfd.close(); + errfd.close(); + + const binary = test_binary(stdout); + + callback({ + ok: true, + command: join(' ', argv), + stdout: binary ? null : stdout, + stderr, + exitcode, + binary + }); + } + else { + callback({ + ok: false, + code: 404, + reason: "No such command" + }); + } +} + +function return_json(result) { + if (result.ok) { + http.prepare_content('application/json'); + http.write_json(result); + } + else { + http.status(result.code, result.reason); + } +} + + +function return_html(result) { + if (result.ok) { + include('commands_public', result); + } + else { + http.status(result.code, result.reason); + } +} + +return { + action_run: function(...args) { + execute_command(return_json, ...args); + }, + + action_download: function(...args) { + const argv = parse_cmdline(...args); + + if (argv) { + const fd = popen(`${join(' ', argv)} 2>/dev/null`); + + if (fd) { + let filename = replace(basename(argv[0]), /\W+/g, '.'); + let chunk = fd.read(4096) ?? ''; + let name; + + if (test_binary(chunk)) { + http.header("Content-Disposition", `attachment; filename=${filename}.bin`); + http.prepare_content("application/octet-stream"); + } + else { + http.header("Content-Disposition", `attachment; filename=${filename}.txt`); + http.prepare_content("text/plain"); + } + + while (length(chunk)) { + http.write(chunk); + chunk = fd.read(4096); + } + + fd.close(); + } + else { + http.status(500, "Failed to execute command"); + } + } + else { + http.status(404, "No such command"); + } + }, + + action_public: function(cmdid, ...args) { + let disp = false; + + if (substr(cmdid, -1) == "s") { + disp = true; + cmdid = substr(cmdid, 0, -1); + } + + if (cmdid && + uci.get('luci', cmdid) == 'command' && + uci.get('luci', cmdid, 'public') == '1') + { + if (disp) + execute_command(return_html, cmdid, ...args); + else + this.action_download(cmdid, args); + } + else { + http.status(403, "Access to command denied"); + } + } +}; diff --git a/applications/luci-app-commands/ucode/template/commands.ut b/applications/luci-app-commands/ucode/template/commands.ut new file mode 100644 index 0000000000..8e5ce0b486 --- /dev/null +++ b/applications/luci-app-commands/ucode/template/commands.ut @@ -0,0 +1,179 @@ +{# + Copyright 2012-2022 Jo-Philipp Wich + Licensed to the public under the Apache License 2.0. +-#} + +{% + include('header', { css: ` + .commands { + display: flex; + flex-wrap: wrap; + } + + .commandbox { + flex: 0 0 30%; + margin: .5em; + display: flex; + flex-direction: column; + } + + .commandbox > p, + .commandbox > p > * { + display: block; + } + + .commandbox div { + margin-top: auto; + } + ` }); +-%} + + + +{% + const commands = []; + + uci.foreach('luci', 'command', s => push(commands, s)); +-%} + +
+
+

{{ _('Custom Commands') }}

+ + {% if (length(commands) == 0): %} +
+
+
+

+ {{ _('This section contains no values yet') }} +

+
+
+
+ {% else %} +
+ {% for (let command in commands): %} +
+

{{ entityencode(command.name) }}

+

{{ _('Command:') }} {{ entityencode(command.command) }}

+ {% if (command.param == "1"): %} +

{{ _('Arguments:') }}

+ {% endif %} +
+ + + {% if (command.public == "1"): %} + + {% endif %} +
+
+ {% endfor %} + + +
+ {% endif %} +
+ + +
+ +{% include('footer') %} diff --git a/applications/luci-app-commands/ucode/template/commands_public.ut b/applications/luci-app-commands/ucode/template/commands_public.ut new file mode 100644 index 0000000000..aef072f802 --- /dev/null +++ b/applications/luci-app-commands/ucode/template/commands_public.ut @@ -0,0 +1,48 @@ +{# + Copyright 2016 t123yh + Copyright 2022 Jo-Philipp Wich + Licensed to the public under the Apache License 2.0. +-#} + +{% + include('header', { blank_page: true, css: ` + .alert-success { + color: #3c763d; + background-color: #dff0d8; + border-color: #d6e9c6; + } + + .alert { + padding: 15px; + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 4px; + } + + .alert-warning { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #faebcc; + } + ` }); +-%} + + + +{% if (length(stdout)): %} +

{{ _('Standard Output') }}

+
{{ entityencode(stdout) }}
+{% endif %} + +{% if (length(stderr)): %} +

{{ _('Standard Error') }}

+
{{ entityencode(stderr) }}
+{% endif %} + +{% include('footer', { blank_page: true }) %} -- 2.30.2