luci-app-commands: Allow executing without downloading on public links
[project/luci.git] / applications / luci-app-commands / luasrc / controller / commands.lua
1 -- Copyright 2012 Jo-Philipp Wich <jow@openwrt.org>
2 -- Licensed to the public under the Apache License 2.0.
3
4 module("luci.controller.commands", package.seeall)
5
6 function index()
7         entry({"admin", "system", "commands"}, firstchild(), _("Custom Commands"), 80)
8         entry({"admin", "system", "commands", "dashboard"}, template("commands"), _("Dashboard"), 1)
9         entry({"admin", "system", "commands", "config"}, cbi("commands"), _("Configure"), 2)
10         entry({"admin", "system", "commands", "run"}, call("action_run"), nil, 3).leaf = true
11         entry({"admin", "system", "commands", "download"}, call("action_download"), nil, 3).leaf = true
12
13         entry({"command"}, call("action_public"), nil, 1).leaf = true
14 end
15
16 --- Decode a given string into arguments following shell quoting rules
17 --- [[abc \def "foo\"bar" abc'def']] -> [[abc def]] [[foo"bar]] [[abcdef]]
18 local function parse_args(str)
19         local args = { }
20
21         local function isspace(c)
22                 if c == 9 or c == 10 or c == 11 or c == 12 or c == 13 or c == 32 then
23                         return c
24                 end
25         end
26
27         local function isquote(c)
28                 if c == 34 or c == 39 or c == 96 then
29                         return c
30                 end
31         end
32
33         local function isescape(c)
34                 if c == 92 then
35                         return c
36                 end
37         end
38
39         local function ismeta(c)
40                 if c == 36 or c == 92 or c == 96 then
41                         return c
42                 end
43         end
44
45         --- Convert given table of byte values into a Lua string and append it to
46         --- the "args" table. Segment byte value sequence into chunks of 256 values
47         --- to not trip over the parameter limit for string.char()
48         local function putstr(bytes)
49                 local chunks = { }
50                 local csz = 256
51                 local upk = unpack
52                 local chr = string.char
53                 local min = math.min
54                 local len = #bytes
55                 local off
56
57                 for off = 1, len, csz do
58                         chunks[#chunks+1] = chr(upk(bytes, off, min(off + csz - 1, len)))
59                 end
60
61                 args[#args+1] = table.concat(chunks)
62         end
63
64         --- Scan substring defined by the indexes [s, e] of the string "str",
65         --- perform unquoting and de-escaping on the fly and store the result in
66         --- a table of byte values which is passed to putstr()
67         local function unquote(s, e)
68                 local off, esc, quote
69                 local res = { }
70
71                 for off = s, e do
72                         local byte = str:byte(off)
73                         local q = isquote(byte)
74                         local e = isescape(byte)
75                         local m = ismeta(byte)
76
77                         if e then
78                                 esc = true
79                         elseif esc then
80                                 if m then res[#res+1] = 92 end
81                                 res[#res+1] = byte
82                                 esc = false
83                         elseif q and quote and q == quote then
84                                 quote = nil
85                         elseif q and not quote then
86                                 quote = q
87                         else
88                                 if m then res[#res+1] = 92 end
89                                 res[#res+1] = byte
90                         end
91                 end
92
93                 putstr(res)
94         end
95
96         --- Find substring boundaries in "str". Ignore escaped or quoted
97         --- whitespace, pass found start- and end-index for each substring
98         --- to unquote()
99         local off, esc, start, quote
100         for off = 1, #str + 1 do
101                 local byte = str:byte(off)
102                 local q = isquote(byte)
103                 local s = isspace(byte) or (off > #str)
104                 local e = isescape(byte)
105
106                 if esc then
107                         esc = false
108                 elseif e then
109                         esc = true
110                 elseif q and quote and q == quote then
111                         quote = nil
112                 elseif q and not quote then
113                         start = start or off
114                         quote = q
115                 elseif s and not quote then
116                         if start then
117                                 unquote(start, off - 1)
118                                 start = nil
119                         end
120                 else
121                         start = start or off
122                 end
123         end
124
125         --- If the "quote" is still set we encountered an unfinished string
126         if quote then
127                 unquote(start, #str)
128         end
129
130         return args
131 end
132
133 local function parse_cmdline(cmdid, args)
134         local uci = require "luci.model.uci".cursor()
135         if uci:get("luci", cmdid) == "command" then
136                 local cmd = uci:get_all("luci", cmdid)
137                 local argv = parse_args(cmd.command)
138                 local i, v
139
140                 if cmd.param == "1" and args then
141                         for i, v in ipairs(parse_args(luci.http.urldecode(args))) do
142                                 argv[#argv+1] = v
143                         end
144                 end
145
146                 for i, v in ipairs(argv) do
147                         if v:match("[^%w%.%-i/]") then
148                                 argv[i] = '"%s"' % v:gsub('"', '\\"')
149                         end
150                 end
151
152                 return argv
153         end
154 end
155
156 function execute_command(callback, ...)
157         local fs = require "nixio.fs"
158         local argv = parse_cmdline(...)
159         if argv then
160                 local outfile = os.tmpname()
161                 local errfile = os.tmpname()
162
163                 local rv = os.execute(table.concat(argv, " ") .. " >%s 2>%s" %{ outfile, errfile })
164                 local stdout = fs.readfile(outfile, 1024 * 512) or ""
165                 local stderr = fs.readfile(errfile, 1024 * 512) or ""
166
167                 fs.unlink(outfile)
168                 fs.unlink(errfile)
169
170                 local binary = not not (stdout:match("[%z\1-\8\14-\31]"))
171
172                 callback({
173                         ok       = true,
174                         command  = table.concat(argv, " "),
175                         stdout   = not binary and stdout,
176                         stderr   = stderr,
177                         exitcode = rv,
178                         binary   = binary
179                 })
180         else
181                 callback({
182                         ok       = false,
183                         code     = 404,
184                         reason   = "No such command"
185                 })
186         end
187 end
188
189 function return_json(result)
190         if result.ok then
191                 luci.http.prepare_content("application/json")
192                 luci.http.write_json(result)
193         else
194                 luci.http.status(result.code, result.reason)
195         end
196 end
197
198 function action_run(...)
199         execute_command(return_json, ...)
200 end
201
202 function return_html(result)
203         if result.ok then
204                 require("luci.template")
205                 luci.template.render("commands_public", {
206                         exitcode = result.exitcode,
207                         stdout = result.stdout,
208                         stderr = result.stderr
209                 })
210         else
211                 luci.http.status(result.code, result.reason)
212         end
213
214 end
215
216 function action_download(...)
217         local fs   = require "nixio.fs"
218         local argv = parse_cmdline(...)
219         if argv then
220                 local fd = io.popen(table.concat(argv, " ") .. " 2>/dev/null")
221                 if fd then
222                         local chunk = fd:read(4096) or ""
223                         local name
224                         if chunk:match("[%z\1-\8\14-\31]") then
225                                 luci.http.header("Content-Disposition", "attachment; filename=%s"
226                                         % fs.basename(argv[1]):gsub("%W+", ".") .. ".bin")
227                                 luci.http.prepare_content("application/octet-stream")
228                         else
229                                 luci.http.header("Content-Disposition", "attachment; filename=%s"
230                                         % fs.basename(argv[1]):gsub("%W+", ".") .. ".txt")
231                                 luci.http.prepare_content("text/plain")
232                         end
233
234                         while chunk do
235                                 luci.http.write(chunk)
236                                 chunk = fd:read(4096)
237                         end
238
239                         fd:close()
240                 else
241                         luci.http.status(500, "Failed to execute command")
242                 end
243         else
244                 luci.http.status(404, "No such command")
245         end
246 end
247
248
249 function action_public(cmdid, args)
250         local disp = false
251         if string.sub(cmdid, -1) == "s" then
252                 disp = true
253                 cmdid = string.sub(cmdid, 1, -2)
254         end
255         local uci = require "luci.model.uci".cursor()
256         if cmdid and
257                 uci:get("luci", cmdid) == "command" and
258                 uci:get("luci", cmdid, "public") == "1"
259                 then
260                         if disp then
261                                 execute_command(return_html, cmdid, args)
262                         else
263                                 action_download(cmdid, args)
264                         end
265                 else
266                         luci.http.status(403, "Access to command denied")
267                 end
268         end