-- 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)
	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