luci-app-commands: rewrite to client side rendering 5976/head
authorJo-Philipp Wich <jo@mein.io>
Mon, 24 Oct 2022 22:55:14 +0000 (00:55 +0200)
committerJo-Philipp Wich <jo@mein.io>
Mon, 24 Oct 2022 23:03:38 +0000 (01:03 +0200)
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 <jo@mein.io>
applications/luci-app-commands/htdocs/luci-static/resources/view/commands.js [new file with mode: 0644]
applications/luci-app-commands/luasrc/controller/commands.lua [deleted file]
applications/luci-app-commands/luasrc/model/cbi/commands.lua [deleted file]
applications/luci-app-commands/luasrc/view/commands.htm [deleted file]
applications/luci-app-commands/luasrc/view/commands_public.htm [deleted file]
applications/luci-app-commands/root/usr/share/luci/menu.d/luci-app-commands.json [new file with mode: 0644]
applications/luci-app-commands/ucode/controller/commands.uc [new file with mode: 0644]
applications/luci-app-commands/ucode/template/commands.ut [new file with mode: 0644]
applications/luci-app-commands/ucode/template/commands_public.ut [new file with mode: 0644]

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 (file)
index 0000000..6d36973
--- /dev/null
@@ -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 (file)
index f6227c6..0000000
+++ /dev/null
@@ -1,268 +0,0 @@
--- Copyright 2012 Jo-Philipp Wich <jow@openwrt.org>
--- 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 (file)
index 7794f15..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
--- Copyright 2012 Jo-Philipp Wich <jow@openwrt.org>
--- 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 (file)
index 634090e..0000000
+++ /dev/null
@@ -1,187 +0,0 @@
-<%#
- Copyright 2012 Jo-Philipp Wich <jow@openwrt.org>
- 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%>
-
-<script type="text/javascript">//<![CDATA[
-       var stxhr = new XHR();
-
-       function command_run(ev, id)
-       {
-               var args;
-               var field = document.getElementById(id);
-               if (field)
-                       args = encodeURIComponent(field.value);
-
-               var legend = document.getElementById('command-rc-legend');
-               var output = document.getElementById('command-rc-output');
-
-               if (legend && output)
-               {
-                       output.innerHTML =
-                               '<img src="<%=resource%>/icons/loading.gif" alt="<%:Loading%>" style="vertical-align:middle" /> ' +
-                               '<%:Waiting for command to complete...%>'
-                       ;
-
-                       legend.parentNode.style.display = 'block';
-                       legend.style.display = 'inline';
-
-                       stxhr.get('<%=url('admin/system/commands/run')%>/' + id + (args ? '/' + args : ''), null,
-                               function(x, st)
-                               {
-                                       if (st)
-                                       {
-                                               if (st.binary)
-                                                       st.stdout = '[<%:Binary data not displayed, download instead.%>]';
-
-                                               legend.style.display = 'none';
-                                               output.innerHTML = String.format(
-                                                       '<pre><strong># %h\n</strong>%h<span style="color:red">%h</span></pre>' +
-                                                       '<div class="alert-message warning">%s (<%:Code:%> %d)</div>',
-                                                       st.command, st.stdout, st.stderr,
-                                                       (st.exitcode == 0) ? '<%:Command successful%>' : '<%:Command failed%>',
-                                                       st.exitcode);
-                                       }
-                                       else
-                                       {
-                                               legend.style.display = 'none';
-                                               output.innerHTML = '<span class="error"><%:Failed to execute command!%></span>';
-                                       }
-
-                                       location.hash = '#output';
-                               }
-                       );
-               }
-
-               ev.preventDefault();
-       }
-
-       function command_download(ev, id)
-       {
-               var args;
-               var field = document.getElementById(id);
-               if (field)
-                       args = encodeURIComponent(field.value);
-
-               location.href = '<%=url('admin/system/commands/download')%>/' + id + (args ? '/' + args : '');
-
-               ev.preventDefault();
-       }
-
-       function command_link(ev, id)
-       {
-               var legend = document.getElementById('command-rc-legend');
-               var output = document.getElementById('command-rc-output');
-
-               var args;
-               var field = document.getElementById(id);
-               if (field)
-                       args = encodeURIComponent(field.value);
-
-               if (legend && output)
-               {
-                       var prefix = location.protocol + '//' + location.host + '<%=url('command')%>/';
-                       var suffix = (args ? '/' + args : '');
-
-                       var link = prefix + id + suffix;
-                       var link_nodownload = prefix + id + "s" + suffix;
-
-                       legend.style.display = 'none';
-                       output.parentNode.style.display = 'block';
-                       output.innerHTML = String.format(
-                               '<div class="alert-message"><p><%:Download execution result%> <a href="%s">%s</a></p><p><%:Or display result%> <a href="%s">%s</a></p></div>',
-                               link, link, link_nodownload, link_nodownload
-                       );
-
-                       location.hash = '#output';
-               }
-
-               ev.preventDefault();
-       }
-
-//]]></script>
-
-<%
-       local uci = require "luci.model.uci".cursor()
-       local commands = { }
-
-       uci:foreach("luci", "command", function(s) commands[#commands+1] = s end)
-%>
-
-<form method="get" action="<%=pcdata(FULL_REQUEST_URI)%>">
-       <div class="cbi-map">
-               <h2 name="content"><%:Custom Commands%></h2>
-               <% if #commands == 0 then %>
-                       <div class="cbi-section">
-                               <div class="table cbi-section-table">
-                                       <div class="tr cbi-section-table-row">
-                                               <p>
-                                                       <em><%:This section contains no values yet%></em>
-                                               </p>
-                                       </div>
-                               </div>
-                       </div>
-               <% else %>
-                       <fieldset class="cbi-section">
-                               <% local _, command; for _, command in ipairs(commands) do %>
-                               <div class="commandbox">
-                                       <h3><%=pcdata(command.name)%></h3>
-                                       <p><%:Command:%> <code><%=pcdata(command.command)%></code></p>
-                                       <% if command.param == "1" then %>
-                                               <p><%:Arguments:%> <input type="text" id="<%=command['.name']%>" /></p>
-                                       <% end %>
-                                       <div>
-                                               <button class="cbi-button cbi-button-apply" onclick="command_run(event, '<%=command['.name']%>')"><%:Run%></button>
-                                               <button class="cbi-button cbi-button-download" onclick="command_download(event, '<%=command['.name']%>')"><%:Download%></button>
-                                               <% if command.public == "1" then %>
-                                                       <button class="cbi-button cbi-button-link" onclick="command_link(event, '<%=command['.name']%>')"><%:Link%></button>
-                                               <% end %>
-                                       </div>
-                               </div>
-                               <% end %>
-
-                               <br style="clear:both" /><br />
-                               <a name="output"></a>
-                       </fieldset>
-               <% end %>
-
-       </div>
-
-       <fieldset class="cbi-section" style="display:none">
-               <legend id="command-rc-legend"><%:Collecting data...%></legend>
-               <span id="command-rc-output"></span>
-       </fieldset>
-</form>
-
-<%+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 (file)
index f20799d..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-<%#
- Copyright 2016 t123yh <t123yh@outlook.com>
- 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 %>
-    <div class="alert alert-success" role="alert"> <%:Command executed successfully.%> </div>
-<% else %>
-    <div class="alert alert-warning" role="alert"> <%:Command exited with status code %> <%= exitcode %> </div>
-<% end %>
-
-<% if stdout ~= "" then %>
-    <h3><%:Standard Output%></h3>
-    <pre><%= stdout %></pre>
-<% end %>
-
-<% if stderr ~= "" then %>
-    <h3><%:Standard Error%></h3>
-    <pre><%= stderr %></pre>
-<% end %>
-
-<script>
-    <%# Display top bar on mobile devices -%>
-    document.getElementsByClassName('brand')[0].style.setProperty("display", "block", "important");
-</script>
-
-<%+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 (file)
index 0000000..8230b14
--- /dev/null
@@ -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 (file)
index 0000000..9126d59
--- /dev/null
@@ -0,0 +1,256 @@
+// Copyright 2012-2022 Jo-Philipp Wich <jow@openwrt.org>
+// 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 (file)
index 0000000..8e5ce0b
--- /dev/null
@@ -0,0 +1,179 @@
+{#
+ Copyright 2012-2022 Jo-Philipp Wich <jo@mein.io>
+ 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;
+               }
+       ` });
+-%}
+
+<script type="text/javascript">//<![CDATA[
+       var stxhr = new XHR();
+
+       function command_run(ev, id)
+       {
+               var args;
+               var field = document.getElementById(id);
+               if (field)
+                       args = encodeURIComponent(field.value);
+
+               var legend = document.getElementById('command-rc-legend');
+               var output = document.getElementById('command-rc-output');
+
+               if (legend && output)
+               {
+                       output.innerHTML =
+                               '<img src="{{ resource }}/icons/loading.gif" alt="{{ _('Loading') }}" style="vertical-align:middle" /> ' +
+                               '{{ _('Waiting for command to complete...') }}'
+                       ;
+
+                       legend.parentNode.style.display = 'block';
+                       legend.style.display = 'inline';
+
+                       stxhr.get('{{ dispatcher.build_url('admin/system/commands/run') }}/' + id + (args ? '?args=' + args : ''), null,
+                               function(x, st)
+                               {
+                                       if (st)
+                                       {
+                                               if (st.binary)
+                                                       st.stdout = '[{{ _('Binary data not displayed, download instead.') }}]';
+
+                                               legend.style.display = 'none';
+                                               output.innerHTML = String.format(
+                                                       '<pre><strong># %h\n</strong>%h<span style="color:red">%h</span></pre>' +
+                                                       '<div class="alert-message warning">%s ({{ _('Code:') }} %d)</div>',
+                                                       st.command, st.stdout, st.stderr,
+                                                       (st.exitcode == 0) ? '{{ _('Command successful') }}' : '{{ _('Command failed') }}',
+                                                       st.exitcode);
+                                       }
+                                       else
+                                       {
+                                               legend.style.display = 'none';
+                                               output.innerHTML = '<span class="error">{{ _('Failed to execute command!') }}</span>';
+                                       }
+
+                                       location.hash = '#output';
+                               }
+                       );
+               }
+
+               ev.preventDefault();
+       }
+
+       function command_download(ev, id)
+       {
+               var args;
+               var field = document.getElementById(id);
+               if (field)
+                       args = encodeURIComponent(field.value);
+
+               location.href = '{{ dispatcher.build_url('admin/system/commands/download') }}/' + id + (args ? '/' + args : '');
+
+               ev.preventDefault();
+       }
+
+       function command_link(ev, id)
+       {
+               var legend = document.getElementById('command-rc-legend');
+               var output = document.getElementById('command-rc-output');
+
+               var args;
+               var field = document.getElementById(id);
+               if (field)
+                       args = encodeURIComponent(field.value);
+
+               if (legend && output)
+               {
+                       var prefix = location.protocol + '//' + location.host + '{{ dispatcher.build_url('command') }}/';
+                       var suffix = (args ? '?args=' + args : '');
+
+                       var link = prefix + id + suffix;
+                       var link_nodownload = prefix + id + "s" + suffix;
+
+                       legend.style.display = 'none';
+                       output.parentNode.style.display = 'block';
+                       output.innerHTML = String.format(
+                               '<div class="alert-message"><p>{{ _('Download execution result') }} <a href="%s">%s</a></p><p>{{ _('Or display result') }} <a href="%s">%s</a></p></div>',
+                               link, link, link_nodownload, link_nodownload
+                       );
+
+                       location.hash = '#output';
+               }
+
+               ev.preventDefault();
+       }
+
+//]]></script>
+
+{%
+       const commands = [];
+
+       uci.foreach('luci', 'command', s => push(commands, s));
+-%}
+
+<form method="get" action="{{ entityencode(FULL_REQUEST_URI) }}">
+       <div class="cbi-map">
+               <h2 name="content">{{ _('Custom Commands') }}</h2>
+
+               {% if (length(commands) == 0): %}
+                       <div class="cbi-section">
+                               <div class="table cbi-section-table">
+                                       <div class="tr cbi-section-table-row">
+                                               <p>
+                                                       <em>{{ _('This section contains no values yet') }}</em>
+                                               </p>
+                                       </div>
+                               </div>
+                       </div>
+               {% else %}
+                       <div class="commands">
+                               {% for (let command in commands): %}
+                               <div class="commandbox">
+                                       <h3>{{ entityencode(command.name) }}</h3>
+                                       <p>{{ _('Command:') }} <code>{{ entityencode(command.command) }}</code></p>
+                                       {% if (command.param == "1"): %}
+                                               <p>{{ _('Arguments:') }} <input type="text" id="{{ command['.name'] }}" /></p>
+                                       {% endif %}
+                                       <div>
+                                               <button class="cbi-button cbi-button-apply" onclick="command_run(event, '{{ command['.name'] }}')">{{ _('Run') }}</button>
+                                               <button class="cbi-button cbi-button-download" onclick="command_download(event, '{{ command['.name'] }}')">{{ _('Download') }}</button>
+                                               {% if (command.public == "1"): %}
+                                                       <button class="cbi-button cbi-button-link" onclick="command_link(event, '{{ command['.name'] }}')">{{ _('Link') }}</button>
+                                               {% endif %}
+                                       </div>
+                               </div>
+                               {% endfor %}
+
+                               <a name="output"></a>
+                       </div>
+               {% endif %}
+       </div>
+
+       <fieldset class="cbi-section" style="display:none">
+               <legend id="command-rc-legend">{{ _('Collecting data...') }}</legend>
+               <span id="command-rc-output"></span>
+       </fieldset>
+</form>
+
+{% 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 (file)
index 0000000..aef072f
--- /dev/null
@@ -0,0 +1,48 @@
+{#
+ Copyright 2016 t123yh <t123yh@outlook.com>
+ Copyright 2022 Jo-Philipp Wich <jo@mein.io>
+ 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;
+        }
+    ` });
+-%}
+
+<div class="alert alert-success" role="alert">
+    {% if (exitcode == 0): %}
+        {{ _('Command executed successfully.') }}
+    {% else %}
+        {{ sprintf(_('Command exited with status code %d'), exitcode) }}
+    {% endif %}
+</div>
+
+{% if (length(stdout)): %}
+    <h3>{{ _('Standard Output') }}</h3>
+    <pre>{{ entityencode(stdout) }}</pre>
+{% endif %}
+
+{% if (length(stderr)): %}
+    <h3>{{ _('Standard Error') }}</h3>
+    <pre>{{ entityencode(stderr) }}</pre>
+{% endif %}
+
+{% include('footer', { blank_page: true }) %}