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 <jo@mein.io>
This commit is contained in:
parent
036424df5b
commit
dd1c538b2e
9 changed files with 573 additions and 532 deletions
|
@ -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();
|
||||
}
|
||||
});
|
|
@ -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
|
|
@ -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
|
|
@ -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%>
|
|
@ -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%>
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
256
applications/luci-app-commands/ucode/controller/commands.uc
Normal file
256
applications/luci-app-commands/ucode/controller/commands.uc
Normal file
|
@ -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");
|
||||
}
|
||||
}
|
||||
};
|
179
applications/luci-app-commands/ucode/template/commands.ut
Normal file
179
applications/luci-app-commands/ucode/template/commands.ut
Normal file
|
@ -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') %}
|
|
@ -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 }) %}
|
Loading…
Reference in a new issue