From 1419042fc09457c2bc0b232324a6b729a7f85dfa Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 4 Dec 2023 10:12:07 +1100 Subject: [PATCH] AP_Scripting: added web server appliction --- .../AP_Scripting/applets/WebExamples/test.lua | 9 + .../applets/WebExamples/test.shtml | 16 + .../AP_Scripting/applets/net_webserver.lua | 694 ++++++++++++++++++ .../AP_Scripting/applets/net_webserver.md | 91 +++ libraries/AP_Scripting/examples/net_test.lua | 37 +- 5 files changed, 818 insertions(+), 29 deletions(-) create mode 100644 libraries/AP_Scripting/applets/WebExamples/test.lua create mode 100644 libraries/AP_Scripting/applets/WebExamples/test.shtml create mode 100644 libraries/AP_Scripting/applets/net_webserver.lua create mode 100644 libraries/AP_Scripting/applets/net_webserver.md diff --git a/libraries/AP_Scripting/applets/WebExamples/test.lua b/libraries/AP_Scripting/applets/WebExamples/test.lua new file mode 100644 index 0000000000..97a5cc4a9e --- /dev/null +++ b/libraries/AP_Scripting/applets/WebExamples/test.lua @@ -0,0 +1,9 @@ +--[[ +example lua cgi file for cgi-bin/ folder +--]] +return [[ +test-from-cgi + +working!! +]] + diff --git a/libraries/AP_Scripting/applets/WebExamples/test.shtml b/libraries/AP_Scripting/applets/WebExamples/test.shtml new file mode 100644 index 0000000000..96546a427f --- /dev/null +++ b/libraries/AP_Scripting/applets/WebExamples/test.shtml @@ -0,0 +1,16 @@ + + + + + +

Server Side Scripting Test

+ + + + + + + +
RollPitchYaw
+ + diff --git a/libraries/AP_Scripting/applets/net_webserver.lua b/libraries/AP_Scripting/applets/net_webserver.lua new file mode 100644 index 0000000000..fb0076717c --- /dev/null +++ b/libraries/AP_Scripting/applets/net_webserver.lua @@ -0,0 +1,694 @@ +--[[ + example script to test lua socket API +--]] + +local MAV_SEVERITY = {EMERGENCY=0, ALERT=1, CRITICAL=2, ERROR=3, WARNING=4, NOTICE=5, INFO=6, DEBUG=7} + +PARAM_TABLE_KEY = 47 +PARAM_TABLE_PREFIX = "WEB_" + +-- add a parameter and bind it to a variable +function bind_add_param(name, idx, default_value) + assert(param:add_param(PARAM_TABLE_KEY, idx, name, default_value), string.format('could not add param %s', name)) + return Parameter(PARAM_TABLE_PREFIX .. name) +end + +-- Setup Parameters +assert(param:add_table(PARAM_TABLE_KEY, PARAM_TABLE_PREFIX, 6), 'net_test: could not add param table') + +--[[ + // @Param: WEB_ENABLE + // @DisplayName: enable web server + // @Description: enable web server + // @Values: 0:Disabled,1:Enabled + // @User: Standard +--]] +local WEB_ENABLE = bind_add_param('ENABLE', 1, 0) + +--[[ + // @Param: WEB_BIND_PORT + // @DisplayName: web server TCP port + // @Description: web server TCP port + // @Range: 1 65535 + // @User: Standard +--]] +local WEB_BIND_PORT = bind_add_param('BIND_PORT', 2, 8080) + +--[[ + // @Param: WEB_DEBUG + // @DisplayName: web server debugging + // @Description: web server debugging + // @Values: 0:Disabled,1:Enabled + // @User: Advanced +--]] +local WEB_DEBUG = bind_add_param('DEBUG', 3, 0) + +--[[ + // @Param: WEB_BLOCK_SIZE + // @DisplayName: web server block size + // @Description: web server block size for download + // @Range: 1 65535 + // @User: Advanced +--]] +local WEB_BLOCK_SIZE = bind_add_param('BLOCK_SIZE', 4, 10240) + +--[[ + // @Param: WEB_TIMEOUT + // @DisplayName: web server timeout + // @Description: timeout for inactive connections + // @Units: s + // @Range: 0.1 60 + // @User: Advanced +--]] +local WEB_TIMEOUT = bind_add_param('TIMEOUT', 5, 2.0) + +gcs:send_text(MAV_SEVERITY.INFO, string.format("WebServer: starting on port %u", WEB_BIND_PORT:get())) + +local counter = 0 +local sock_listen = SocketAPM(0) +local clients = {} + +local DOCTYPE = "" +local SERVER_VERSION = "net_webserver 1.0" +local CONTENT_TEXT_HTML = "text/html;charset=UTF-8" +local CONTENT_OCTET_STREAM = "application/octet-stream" + +local HIDDEN_FOLDERS = { "@SYS", "@ROMFS", "@MISSION", "@PARAM" } + +local CGI_BIN_PATH = "cgi-bin" + +local MIME_TYPES = { + ["apj"] = CONTENT_OCTET_STREAM, + ["dat"] = CONTENT_OCTET_STREAM, + ["o"] = CONTENT_OCTET_STREAM, + ["obj"] = CONTENT_OCTET_STREAM, + ["lua"] = "text/x-lua", + ["py"] = "text/x-python", + ["shtml"] = CONTENT_TEXT_HTML, + ["js"] = "text/javascript", + -- thanks to https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types + ["aac"] = "audio/aac", + ["abw"] = "application/x-abiword", + ["arc"] = "application/x-freearc", + ["avif"] = "image/avif", + ["avi"] = "video/x-msvideo", + ["azw"] = "application/vnd.amazon.ebook", + ["bin"] = "application/octet-stream", + ["bmp"] = "image/bmp", + ["bz"] = "application/x-bzip", + ["bz2"] = "application/x-bzip2", + ["cda"] = "application/x-cdf", + ["csh"] = "application/x-csh", + ["css"] = "text/css", + ["csv"] = "text/csv", + ["doc"] = "application/msword", + ["docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ["eot"] = "application/vnd.ms-fontobject", + ["epub"] = "application/epub+zip", + ["gz"] = "application/gzip", + ["gif"] = "image/gif", + ["htm"] = CONTENT_TEXT_HTML, + ["html"] = CONTENT_TEXT_HTML, + ["ico"] = "image/vnd.microsoft.icon", + ["ics"] = "text/calendar", + ["jar"] = "application/java-archive", + ["jpeg"] = "image/jpeg", + ["json"] = "application/json", + ["jsonld"] = "application/ld+json", + ["mid"] = "audio/x-midi", + ["mjs"] = "text/javascript", + ["mp3"] = "audio/mpeg", + ["mp4"] = "video/mp4", + ["mpeg"] = "video/mpeg", + ["mpkg"] = "application/vnd.apple.installer+xml", + ["odp"] = "application/vnd.oasis.opendocument.presentation", + ["ods"] = "application/vnd.oasis.opendocument.spreadsheet", + ["odt"] = "application/vnd.oasis.opendocument.text", + ["oga"] = "audio/ogg", + ["ogv"] = "video/ogg", + ["ogx"] = "application/ogg", + ["opus"] = "audio/opus", + ["otf"] = "font/otf", + ["png"] = "image/png", + ["pdf"] = "application/pdf", + ["php"] = "application/x-httpd-php", + ["ppt"] = "application/vnd.ms-powerpoint", + ["pptx"] = "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ["rar"] = "application/vnd.rar", + ["rtf"] = "application/rtf", + ["sh"] = "application/x-sh", + ["svg"] = "image/svg+xml", + ["tar"] = "application/x-tar", + ["tif"] = "image/tiff", + ["tiff"] = "image/tiff", + ["ts"] = "video/mp2t", + ["ttf"] = "font/ttf", + ["txt"] = "text/plain", + ["vsd"] = "application/vnd.visio", + ["wav"] = "audio/wav", + ["weba"] = "audio/webm", + ["webm"] = "video/webm", + ["webp"] = "image/webp", + ["woff"] = "font/woff", + ["woff2"] = "font/woff2", + ["xhtml"] = "application/xhtml+xml", + ["xls"] = "application/vnd.ms-excel", + ["xlsx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ["xml"] = "default.", + ["xul"] = "application/vnd.mozilla.xul+xml", + ["zip"] = "application/zip", + ["3gp"] = "video", + ["3g2"] = "video", + ["7z"] = "application/x-7z-compressed", +} + + +if not sock_listen:bind("0.0.0.0", WEB_BIND_PORT:get()) then + gcs:send_text(MAV_SEVERITY.ERROR, string.format("WebServer: failed to bind to TCP %u", WEB_BIND_PORT:get())) + return +end + +if not sock_listen:listen(50) then + gcs:send_text(MAV_SEVERITY.ERROR, "WebServer: failed to listen") + return +end + +--[[ + split string by pattern +--]] +local function split(str, pattern) + local ret = {} + for s in string.gmatch(str, pattern) do + table.insert(ret, s) + end + return ret +end + +--[[ + return true if a string ends in the 2nd string +--]] +local function endswith(str, s) + local len1 = #str + local len2 = #s + return string.sub(str,1+len1-len2,len1) == s +end + +--[[ + return true if a string starts with the 2nd string +--]] +local function startswith(str, s) + local len1 = #str + local len2 = #s + return string.sub(str,1,len2) == s +end + +function DEBUG(txt) + if WEB_DEBUG:get() ~= 0 then + gcs:send_text(MAV_SEVERITY.DEBUG, txt) + end +end + +--[[ + return true if a table contains a given element +--]] +function contains(t,el) + for _,v in ipairs(t) do + if v == el then + return true + end + end + return false +end + +function is_hidden_dir(path) + return contains(HIDDEN_FOLDERS, path) +end + +function file_exists(path) + local f = io.open(path, "rb") + if f then + return true + end + return false +end + +--[[ + client class for open connections +--]] +local function Client(_sock, _idx) + local self = {} + + self.closed = false + + local sock = _sock + local idx = _idx + local have_header = false + local header = "" + local header_lines = {} + local header_vars = {} + local run = nil + local protocol = nil + local file = nil + local start_time = millis() + + function self.read_header() + local s = sock:recv(2048) + if not s then + local now = millis() + if not sock:is_connected() or now - start_time > WEB_TIMEOUT:get()*1000 then + -- EOF while looking for header + DEBUG("EOF") + self.remove() + return false + end + return false + end + if not s or #s == 0 then + return false + end + header = header .. s + local eoh = string.find(s, '\r\n\r\n') + if eoh then + DEBUG("got header") + have_header = true + header_lines = split(header, "[^\r\n]+") + -- blocking for reply + sock:set_blocking(true) + return true + end + return false + end + + function self.sendstring(s) + sock:send(s, #s) + end + + function self.sendline(s) + self.sendstring(s .. "\r\n") + end + + --[[ + send a string with variable substitution using {varname} + from http://lua-users.org/wiki/StringInterpolation + --]] + function self.sendstring_vars(s, vars) + s = (string.gsub(s, "({([^}]+)})", + function(whole,i) + return vars[i] or whole + end)) + self.sendstring(s) + end + + function self.send_header(code, codestr, vars) + self.sendline(string.format("%s %u %s", protocol, code, codestr)) + self.sendline(string.format("Server: %s", SERVER_VERSION)) + for k,v in pairs(vars) do + self.sendline(string.format("%s: %s", k, v)) + end + self.sendline("Connection: close") + self.sendline("") + end + + -- get size of a file + function self.file_size(fname) + DEBUG(string.format("size of '%s'", fname)) + local f = io.open(fname, "rb") + if not f then + return -1 + end + local ret = f:seek("end") + f:close() + DEBUG(string.format("size of '%s' -> %u", fname, ret)) + return ret + end + + + --[[ + return full path with .. resolution + --]] + function self.full_path(path, name) + DEBUG(string.format("full_path(%s,%s)", path, name)) + local ret = path + if path == "/" and startswith(name,"@") then + return name + end + if name == ".." then + if path == "/" then + return "/" + end + if endswith(path,"/") then + path = string.sub(path, 1, #path-1) + end + local dir, file = string.match(path, '(.*/)(.*)') + if not dir then + return path + end + return dir + end + if not endswith(ret, "/") then + ret = ret .. "/" + end + ret = ret .. name + DEBUG(string.format("full_path(%s,%s) -> %s", path, name, ret)) + return ret + end + + function self.directory_list(path) + if startswith(path, "/@") then + path = string.sub(path, 2, #path-1) + end + DEBUG(string.format("directory_list(%s)", path)) + local dlist = dirlist(path) + if not dlist then + dlist = {} + end + if not contains(dlist, "..") then + -- on ChibiOS we don't get .. + table.insert(dlist, "..") + end + if path == "/" then + for _,v in ipairs(HIDDEN_FOLDERS) do + table.insert(dlist, v) + end + end + + table.sort(dlist) + self.send_header(200, "OK", {["Content-Type"]=CONTENT_TEXT_HTML}) + self.sendline(DOCTYPE) + self.sendstring_vars([[ + + + Index of {path} + + +

Index of {path}

+ + +]], {path=path}) + for _,d in ipairs(dlist) do + local skip = d == "." + if path == "/" and d == ".." then + skip = true + end + if not skip then + local fullpath = self.full_path(path, d) + local name = d + local size = 0 + if is_hidden_dir(fullpath) or isdirectory(fullpath) then + name = name .. "/" + else + size = math.max(self.file_size(fullpath),0) + end + self.sendstring_vars([[ +]], { name=name, size=tostring(size) }) + end + end + self.sendstring([[ +
NameSize
{name}{size}
+ + +]]) + end + + -- send file content + function self.send_file() + local chunk = WEB_BLOCK_SIZE:get() + local b = file:read(chunk) + if b and #b > 0 then + sock:send(b, #b) + end + if not b or #b < chunk then + -- EOF + DEBUG("sent file") + run = nil + self.remove() + return + end + end + + --[[ + load whole file as a string + --]] + function self.load_file() + local chunk = WEB_BLOCK_SIZE:get() + local ret = "" + while true do + local b = file:read(chunk) + if not b or #b == 0 then + break + end + ret = ret .. b + end + return ret + end + + --[[ + evaluate some lua code and return as a string + --]] + function self.evaluate(code) + local eval_code = "function eval_func()\n" .. code .. "\nend\n" + local f, errloc, err = load(eval_code, "eval_func", "t", _ENV) + if not f then + DEBUG(string.format("load failed: err=%s", err)) + return nil + end + local success, err = pcall(f) + if not success then + DEBUG(string.format("pcall failed: err=%s", err)) + return nil + end + local ok, s2 = pcall(eval_func) + eval_func = nil + if ok then + return s2 + end + return nil + end + + --[[ + process a file as a lua CGI + --]] + function self.send_cgi() + local contents = self.load_file() + local s = self.evaluate(contents) + if s then + self.sendstring(s) + end + self.remove() + end + + --[[ + send file content with server side processsing + files ending in .shtml can have embedded lua lika this: + + + + Using 'lstr' a return tostring(yourcode) is added to the code + automatically + --]] + function self.send_processed_file() + local contents = self.load_file() + while #contents > 0 do + local pat1 = "(.-)[<][?]lua[ \n](.-)[?][>](.*)" + local pat2 = "(.-)[<][?]lstr[ \n](.-)[?][>](.*)" + local p1, p2, p3 = string.match(contents, pat1) + if not p1 then + p1, p2, p3 = string.match(contents, pat2) + if not p1 then + break + end + p2 = "return tostring(" .. p2 .. ")" + end + self.sendstring(p1) + local s2 = self.evaluate(p2) + if s2 then + self.sendstring(s2) + end + contents = p3 + end + self.sendstring(contents) + self.remove() + end + + -- return a content type + function self.content_type(path) + local file, ext = string.match(path, '(.*[.])(.*)') + ext = string.lower(ext) + local ret = MIME_TYPES[ext] + if not ret then + return CONTENT_OCTET_STREAM + end + return ret + end + + -- perform a file download + function self.file_download(path) + if startswith(path, "/@") then + path = string.sub(path, 2, #path) + end + DEBUG(string.format("file_download(%s)", path)) + file = io.open(path,"rb") + if not file then + DEBUG(string.format("Failed to open '%s'", path)) + return false + end + local vars = {["Content-Type"]=self.content_type(path)} + local cgi_processing = startswith(path, "/cgi-bin/") and endswith(path, ".lua") + local server_side_processing = endswith(path, ".shtml") + if not startswith(path, "@") and not server_side_processing and not cgi_processing then + local fsize = self.file_size(path) + vars["Content-Length"]= tostring(fsize) + end + self.send_header(200, "OK", vars) + if server_side_processing then + DEBUG(string.format("shtml processing %s", path)) + run = self.send_processed_file + elseif cgi_processing then + DEBUG(string.format("CGI processing %s", path)) + run = self.send_cgi + else + run = self.send_file + end + return true + end + + function self.not_found() + self.send_header(404, "Not found", {}) + end + + function self.moved_permanently(relpath) + if not startswith(relpath, "/") then + relpath = "/" .. relpath + end + local location = string.format("http://%s%s", header_vars['Host'], relpath) + DEBUG(string.format("Redirect -> %s", location)) + self.send_header(301, "Moved Permanently", {["Location"]=location}) + end + + -- process a single request + function self.process_request() + local h1 = header_lines[1] + if not h1 or #h1 == 0 then + DEBUG("empty request") + return + end + local cmd = split(header_lines[1], "%S+") + if not cmd or #cmd < 3 then + DEBUG(string.format("bad request: %s", header_lines[1])) + return + end + if cmd[1] ~= "GET" then + DEBUG(string.format("bad op: %s", cmd[1])) + return + end + protocol = cmd[3] + if protocol ~= "HTTP/1.0" and protocol ~= "HTTP/1.1" then + DEBUG(string.format("bad protocol: %s", protocol)) + return + end + local path = cmd[2] + DEBUG(string.format("path='%s'", path)) + + -- extract header variables + for i = 2,#header_lines do + local key, var = string.match(header_lines[i], '(.*): (.*)') + header_vars[key] = var + end + + if isdirectory(path) and not endswith(path,"/") and header_vars['Host'] and not is_hidden_dir(path) then + self.moved_permanently(path .. "/") + return + end + + if path ~= "/" and endswith(path,"/") then + path = string.sub(path, 1, #path-1) + end + + if startswith(path,"/@") then + path = string.sub(path, 2, #path) + end + + -- see if we have an index file + if isdirectory(path) and file_exists(path .. "/index.html") then + DEBUG("found index.html") + if self.file_download(path .. "/index.html") then + return + end + end + + -- see if it is a directory + if endswith(path,"/") or isdirectory(path) or is_hidden_dir(path) then + self.directory_list(path) + return + end + -- or a file + if self.file_download(path) then + return + end + self.not_found(path) + end + + -- update the client + function self.update() + if run then + run() + return + end + if not have_header then + if not self.read_header() then + return + end + end + self.process_request() + if not run then + -- nothing more to do + self.remove() + end + end + + function self.remove() + DEBUG(string.format("Removing client %u", idx)) + sock:close() + self.closed = true + end + + -- return the instance + return self +end + +--[[ + see if any new clients want to connect +--]] +local function check_new_clients() + local sock = sock_listen:accept() + if sock then + -- non-blocking for header read + sock:set_blocking(false) + local idx = #clients+1 + local client = Client(sock, idx) + DEBUG(string.format("New client %u", idx)) + table.insert(clients, client) + end +end + +--[[ + check for client activity +--]] +local function check_clients() + for idx,client in ipairs(clients) do + if not client.closed then + client.update() + end + if client.closed then + table.remove(clients,idx) + end + end +end + +local function update() + check_clients() + check_new_clients() + return update,1 +end + +return update,100 diff --git a/libraries/AP_Scripting/applets/net_webserver.md b/libraries/AP_Scripting/applets/net_webserver.md new file mode 100644 index 0000000000..fa45efe780 --- /dev/null +++ b/libraries/AP_Scripting/applets/net_webserver.md @@ -0,0 +1,91 @@ +# Web Server Application + +This implements a web server for boards that have networking support. + +# Parameters + +The web server has a small number of parameters + +## WEB_ENABLE + +This must be set to 1 to enable the web server + +## WEB_BIND_PORT + +This sets the network port to use for the server. It defaults to 8080 + +## WEB_DEBUG + +This enables verbose debugging + +## WEB_BLOCK_SIZE + +This sets the block size for network and file read/write +operations. Setting a larger value can increase performance at the +cost of more memory + +## WEB_TIMEOUT + +This sets the timeout in seconds for inactive client connections. + +# Operation + +By default the web server serves the root of your microSD card. You +can include html, javascript (*.js), image files etc on your microSD +to create a full web server with any structure you want. + +## Server Side Scripting + +The web server supports embedding lua script elements inside html +files for files with a filename of *.shtml. Here is an example: + +``` + + + + + +

Server Side Scripting Test

+ + + + + + + +
RollPitchYaw
+ + +``` +In this example we are using two forms of embedded lua scripts. The +first form starts with "