diff --git a/.gitmodules b/.gitmodules index 1785eb0..3279e9f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "extern/nodemcu-uploader"] path = extern/nodemcu-uploader url = https://github.com/kmpm/nodemcu-uploader +[submodule "extern/nodemcu-httpserver"] + path = extern/nodemcu-httpserver + url = https://github.com/marcoskirsch/nodemcu-httpserver diff --git a/extern/nodemcu-httpserver b/extern/nodemcu-httpserver new file mode 160000 index 0000000..b84739d --- /dev/null +++ b/extern/nodemcu-httpserver @@ -0,0 +1 @@ +Subproject commit b84739dc1bee35800f933a78ebd56492503b29ae diff --git a/nodemcu/httpserver-basicauth.lua b/nodemcu/httpserver-basicauth.lua index 8ff7d25..09d4f78 100644 --- a/nodemcu/httpserver-basicauth.lua +++ b/nodemcu/httpserver-basicauth.lua @@ -4,11 +4,11 @@ basicAuth = {} +-- Parse basic auth http header. +-- Returns the username if header contains valid credentials, +-- nil otherwise. function basicAuth.authenticate(header) - conf = dofile("httpserver-conf.lc") - -- Parse basic auth http header. - -- Returns the username if header contains valid credentials, - -- nil otherwise. + local conf = dofile("httpserver-conf.lc") local credentials_enc = header:match("Authorization: Basic ([A-Za-z0-9+/=]+)") if not credentials_enc then return nil @@ -16,13 +16,15 @@ function basicAuth.authenticate(header) local credentials = dofile("httpserver-b64decode.lc")(credentials_enc) local user, pwd = credentials:match("^(.*):(.*)$") if user ~= conf.auth.user or pwd ~= conf.auth.password then + print("httpserver-basicauth: User \"" .. user .. "\": Access denied.") return nil end - print("httpserver-basicauth: User \"" .. user .. "\" authenticated.") + print("httpserver-basicauth: User \"" .. user .. "\": Authenticated.") return user end function basicAuth.authErrorHeader() + local conf = dofile("httpserver-conf.lc") return "WWW-Authenticate: Basic realm=\"" .. conf.auth.realm .. "\"" end diff --git a/nodemcu/httpserver-error.lua b/nodemcu/httpserver-error.lua index b4dba56..fd9e9ae 100644 --- a/nodemcu/httpserver-error.lua +++ b/nodemcu/httpserver-error.lua @@ -4,16 +4,19 @@ return function (connection, req, args) - local function sendHeader(connection, code, errorString, extraHeaders, mimeType) - connection:send("HTTP/1.0 " .. code .. " " .. errorString .. "\r\nServer: nodemcu-httpserver\r\nContent-Type: " .. mimeType .. "\r\n") - for i, header in ipairs(extraHeaders) do - connection:send(header .. "\r\n") - end - connection:send("connection: close\r\n\r\n") + -- @TODO: would be nice to use httpserver-header.lua + local function getHeader(connection, code, errorString, extraHeaders, mimeType) + local header = "HTTP/1.0 " .. code .. " " .. errorString .. "\r\nServer: nodemcu-httpserver\r\nContent-Type: " .. mimeType .. "\r\n" + for i, extraHeader in ipairs(extraHeaders) do + header = header .. extraHeader .. "\r\n" + end + header = header .. "connection: close\r\n\r\n" + return header end print("Error " .. args.code .. ": " .. args.errorString) args.headers = args.headers or {} - sendHeader(connection, args.code, args.errorString, args.headers, "text/html") + connection:send(getHeader(connection, args.code, args.errorString, args.headers, "text/html")) connection:send("" .. args.code .. " - " .. args.errorString .. "

" .. args.code .. " - " .. args.errorString .. "

\r\n") + end diff --git a/nodemcu/httpserver-header.lua b/nodemcu/httpserver-header.lua index 5f01ef7..d218722 100644 --- a/nodemcu/httpserver-header.lua +++ b/nodemcu/httpserver-header.lua @@ -2,7 +2,7 @@ -- Part of nodemcu-httpserver, knows how to send an HTTP header. -- Author: Marcos Kirsch -return function (connection, code, extension) +return function (connection, code, extension, isGzipped) local function getHTTPStatusString(code) local codez = {[200]="OK", [400]="Bad Request", [404]="Not Found",} @@ -12,24 +12,18 @@ return function (connection, code, extension) end local function getMimeType(ext) - local gzip = false -- A few MIME types. Keep list short. If you need something that is missing, let's add it. local mt = {css = "text/css", gif = "image/gif", html = "text/html", ico = "image/x-icon", jpeg = "image/jpeg", jpg = "image/jpeg", js = "application/javascript", json = "application/json", png = "image/png", xml = "text/xml"} - -- add comressed flag if file ends with gz - if ext:find("%.gz$") then - ext = ext:sub(1, -4) - gzip = true - end - if mt[ext] then contentType = mt[ext] else contentType = "text/plain" end - return {contentType = contentType, gzip = gzip} + if mt[ext] then return mt[ext] else return "text/plain" end end local mimeType = getMimeType(extension) - connection:send("HTTP/1.0 " .. code .. " " .. getHTTPStatusString(code) .. "\r\nServer: nodemcu-httpserver\r\nContent-Type: " .. mimeType["contentType"] .. "\r\n") - if mimeType["gzip"] then - connection:send("Content-Encoding: gzip\r\n") + connection:send("HTTP/1.0 " .. code .. " " .. getHTTPStatusString(code) .. "\r\nServer: nodemcu-httpserver\r\nContent-Type: " .. mimeType .. "\r\nnCache-Control: private, no-store\r\n") + if isGzipped then + connection:send("Cache-Control: max-age=2592000\r\nContent-Encoding: gzip\r\n") end connection:send("Connection: close\r\n\r\n") + end diff --git a/nodemcu/httpserver-request.lua b/nodemcu/httpserver-request.lua index 121dbf8..bf51d49 100644 --- a/nodemcu/httpserver-request.lua +++ b/nodemcu/httpserver-request.lua @@ -32,45 +32,43 @@ local function parseArgs(args) end local function parseFormData(body) - local data = {} - print("Parsing Form Data") - for kv in body.gmatch(body, "%s*&?([^=]+=[^&]+)") do - local key, value = string.match(kv, "(.*)=(.*)") - - print("Parsed: " .. key .. " => " .. value) - data[key] = uri_decode(value) - end - - return data + local data = {} + --print("Parsing Form Data") + for kv in body.gmatch(body, "%s*&?([^=]+=[^&]+)") do + local key, value = string.match(kv, "(.*)=(.*)") + --print("Parsed: " .. key .. " => " .. value) + data[key] = uri_decode(value) + end + return data end local function getRequestData(payload) - local requestData - return function () - print("Getting Request Data") - if requestData then - return requestData - else - local mimeType = string.match(payload, "Content%-Type: (%S+)\r\n") - local body_start = payload:find("\r\n\r\n", 1, true) - local body = payload:sub(body_start, #payload) - payload = nil - collectgarbage() - - -- print("mimeType = [" .. mimeType .. "]") - - if mimeType == "application/json" then - print("JSON: " .. body) - requestData = cjson.decode(body) - elseif mimeType == "application/x-www-form-urlencoded" then - requestData = parseFormData(body) + local requestData + return function () + --print("Getting Request Data") + if requestData then + return requestData else - requestData = {} + --print("payload = [" .. payload .. "]") + local mimeType = string.match(payload, "Content%-Type: ([%w/-]+)") + local bodyStart = payload:find("\r\n\r\n", 1, true) + local body = payload:sub(bodyStart, #payload) + payload = nil + collectgarbage() + --print("mimeType = [" .. mimeType .. "]") + --print("bodyStart = [" .. bodyStart .. "]") + --print("body = [" .. body .. "]") + if mimeType == "application/json" then + --print("JSON: " .. body) + requestData = cjson.decode(body) + elseif mimeType == "application/x-www-form-urlencoded" then + requestData = parseFormData(body) + else + requestData = {} + end + return requestData end - - return requestData - end - end + end end local function parseUri(uri) @@ -94,7 +92,12 @@ local function parseUri(uri) filename,ext = filename:match("(.+)%.(.+)") table.insert(fullExt,1,ext) end - r.ext = table.concat(fullExt,".") + if #fullExt > 1 and fullExt[#fullExt] == 'gz' then + r.ext = fullExt[#fullExt-1] + r.isGzipped = true + elseif #fullExt >= 1 then + r.ext = fullExt[#fullExt] + end r.isScript = r.ext == "lua" or r.ext == "lc" r.file = uriToFilename(r.file) return r @@ -103,6 +106,7 @@ end -- Parses the client's request. Returns a dictionary containing pretty much everything -- the server needs to know about the uri. return function (request) + --print("Request: \n", request) local e = request:find("\r\n", 1, true) if not e then return nil end local line = request:sub(1, e - 1) diff --git a/nodemcu/httpserver-static.lua b/nodemcu/httpserver-static.lua index 93ecc2a..178b17b 100644 --- a/nodemcu/httpserver-static.lua +++ b/nodemcu/httpserver-static.lua @@ -3,29 +3,32 @@ -- Author: Marcos Kirsch return function (connection, req, args) - dofile("httpserver-header.lc")(connection, 200, args.ext) --print("Begin sending:", args.file) + --print("node.heap(): ", node.heap()) + dofile("httpserver-header.lc")(connection, 200, args.ext, args.isGzipped) -- Send file in little chunks local continue = true + local size = file.list()[args.file] local bytesSent = 0 + -- Chunks larger than 1024 don't work. + -- https://github.com/nodemcu/nodemcu-firmware/issues/1075 + local chunkSize = 1024 while continue do collectgarbage() + -- NodeMCU file API lets you open 1 file at a time. -- So we need to open, seek, close each time in order -- to support multiple simultaneous clients. file.open(args.file) file.seek("set", bytesSent) - local chunk = file.read(256) + local chunk = file.read(chunkSize) file.close() - if chunk == nil then - continue = false - else - coroutine.yield() - connection:send(chunk) - bytesSent = bytesSent + #chunk - chunk = nil - --print("Sent" .. args.file, bytesSent) - end + + connection:send(chunk) + bytesSent = bytesSent + #chunk + chunk = nil + --print("Sent: " .. bytesSent .. " of " .. size) + if bytesSent == size then continue = false end end - --print("Finished sending:", args.file) + --print("Finished sending: ", args.file) end diff --git a/nodemcu/httpserver.lua b/nodemcu/httpserver.lua index 2b0376a..29ac0a2 100644 --- a/nodemcu/httpserver.lua +++ b/nodemcu/httpserver.lua @@ -9,21 +9,37 @@ return function (port) port, function (connection) - -- This variable holds the thread used for sending data back to the user. - -- We do it in a separate thread because we need to yield when sending lots - -- of data in order to avoid overflowing the mcu's buffer. + -- This variable holds the thread (actually a Lua coroutine) used for sending data back to the user. + -- We do it in a separate thread because we need to send in little chunks and wait for the onSent event + -- before we can send more, or we risk overflowing the mcu's buffer. local connectionThread - + local allowStatic = {GET=true, HEAD=true, POST=false, PUT=false, DELETE=false, TRACE=false, OPTIONS=false, CONNECT=false, PATCH=false} - local function onRequest(connection, req) + local function startServing(fileServeFunction, connection, req, args) + connectionThread = coroutine.create(function(fileServeFunction, bufferedConnection, req, args) + fileServeFunction(bufferedConnection, req, args) + -- The bufferedConnection may still hold some data that hasn't been sent. Flush it before closing. + if not bufferedConnection:flush() then + connection:close() + connectionThread = nil + end + end) + + local BufferedConnectionClass = dofile("httpserver-connection.lc") + local bufferedConnection = BufferedConnectionClass:new(connection) + local status, err = coroutine.resume(connectionThread, fileServeFunction, bufferedConnection, req, args) + if not status then + print("Error: ", err) + end + end + + local function handleRequest(connection, req) collectgarbage() local method = req.method local uri = req.uri local fileServeFunction = nil - - print("Method: " .. method); - + if #(uri.file) > 32 then -- nodemcu-firmware cannot handle long filenames. uri.args = {code = 400, errorString = "Bad Request"} @@ -31,17 +47,17 @@ return function (port) else local fileExists = file.open(uri.file, "r") file.close() - - if not fileExists then - -- gzip check - fileExists = file.open(uri.file .. ".gz", "r") - file.close() - if fileExists then - print("gzip variant exists, serving that one") - uri.file = uri.file .. ".gz" - uri.ext = uri.ext .. ".gz" - end + if not fileExists then + -- gzip check + fileExists = file.open(uri.file .. ".gz", "r") + file.close() + + if fileExists then + --print("gzip variant exists, serving that one") + uri.file = uri.file .. ".gz" + uri.isGzipped = true + end end if not fileExists then @@ -51,16 +67,15 @@ return function (port) fileServeFunction = dofile(uri.file) else if allowStatic[method] then - uri.args = {file = uri.file, ext = uri.ext} - fileServeFunction = dofile("httpserver-static.lc") + uri.args = {file = uri.file, ext = uri.ext, isGzipped = uri.isGzipped} + fileServeFunction = dofile("httpserver-static.lc") else - uri.args = {code = 405, errorString = "Method not supported"} - fileServeFunction = dofile("httpserver-error.lc") + uri.args = {code = 405, errorString = "Method not supported"} + fileServeFunction = dofile("httpserver-error.lc") end end end - connectionThread = coroutine.create(fileServeFunction) - coroutine.resume(connectionThread, connection, req, uri.args) + startServing(fileServeFunction, connection, req, uri.args) end local function onReceive(connection, payload) @@ -69,16 +84,32 @@ return function (port) local auth local user = "Anonymous" + -- as suggest by anyn99 (https://github.com/marcoskirsch/nodemcu-httpserver/issues/36#issuecomment-167442461) + -- Some browsers send the POST data in multiple chunks. + -- Collect data packets until the size of HTTP body meets the Content-Length stated in header + if payload:find("Content%-Length:") or bBodyMissing then + if fullPayload then fullPayload = fullPayload .. payload else fullPayload = payload end + if (tonumber(string.match(fullPayload, "%d+", fullPayload:find("Content%-Length:")+16)) > #fullPayload:sub(fullPayload:find("\r\n\r\n", 1, true)+4, #fullPayload)) then + bBodyMissing = true + return + else + --print("HTTP packet assembled! size: "..#fullPayload) + payload = fullPayload + fullPayload, bBodyMissing = nil + end + end + collectgarbage() + -- parse payload and decide what to serve. local req = dofile("httpserver-request.lc")(payload) - print("Requested URI: " .. req.request) + print(req.method .. ": " .. req.request) if conf.auth.enabled then auth = dofile("httpserver-basicauth.lc") user = auth.authenticate(payload) -- authenticate returns nil on failed auth end if user and req.methodIsValid and (req.method == "GET" or req.method == "POST" or req.method == "PUT") then - onRequest(connection, req) + handleRequest(connection, req) else local args = {} local fileServeFunction = dofile("httpserver-error.lc") @@ -89,8 +120,7 @@ return function (port) else args = {code = 400, errorString = "Bad Request"} end - connectionThread = coroutine.create(fileServeFunction) - coroutine.resume(connectionThread, connection, req, args) + startServing(fileServeFunction, connection, req, args) end end @@ -100,7 +130,10 @@ return function (port) local connectionThreadStatus = coroutine.status(connectionThread) if connectionThreadStatus == "suspended" then -- Not finished sending file, resume. - coroutine.resume(connectionThread) + local status, err = coroutine.resume(connectionThread) + if not status then + print(err) + end elseif connectionThreadStatus == "dead" then -- We're done sending file. connection:close() @@ -109,14 +142,23 @@ return function (port) end end + local function onDisconnect(connection, payload) + if connectionThread then + connectionThread = nil + collectgarbage() + end + end + connection:on("receive", onReceive) connection:on("sent", onSent) + connection:on("disconnection", onDisconnect) end ) -- false and nil evaluate as false local ip = wifi.sta.getip() if not ip then ip = wifi.ap.getip() end + if not ip then ip = "unknown IP" end print("nodemcu-httpserver running at http://" .. ip .. ":" .. port) return s diff --git a/nodemcu/init.lua b/nodemcu/init.lua index 6440529..c9f6825 100644 --- a/nodemcu/init.lua +++ b/nodemcu/init.lua @@ -39,22 +39,23 @@ local compileAndRemoveIfNeeded = function(f) end end -local serverFiles = {'telnet.lua', 'httpserver.lua', 'httpserver-basicauth.lua', 'httpserver-conf.lua', 'httpserver-b64decode.lua', 'httpserver-request.lua', 'httpserver-static.lua', 'httpserver-header.lua', 'httpserver-error.lua', 'wificonfig.lua'} +local serverFiles = {'httpserver.lua', 'httpserver-basicauth.lua', 'httpserver-conf.lua', 'httpserver-b64decode.lua', 'httpserver-request.lua', 'httpserver-static.lua', 'httpserver-header.lua', 'httpserver-error.lua'} for i, f in ipairs(serverFiles) do compileAndRemoveIfNeeded(f) end compileAndRemoveIfNeeded = nil serverFiles = nil -- Check for a wifi config file -if file.open("wificonfig.lc") then +if file.open("wificonfig.lua") then file.close() print('Found config.') - wifiConfig.stationPointConfig = dofile("wificonfig.lc") + wifiConfig.stationPointConfig = dofile("wificonfig.lua") end -- Start the APs once we hit here wifi.ap.config(wifiConfig.accessPointConfig) wifi.sta.config(wifiConfig.stationPointConfig.ssid, wifiConfig.stationPointConfig.pwd) +wifi.ap.dhcp.start() wifiConfig = nil collectgarbage() @@ -73,9 +74,9 @@ tmr.alarm(0, 3000, 1, function() else if joinCounter == joinMaxAttempts then print('Failed to connect to WiFi Access Point.') - mdns.register("rvoots-nameplate", {}) else print('IP: ',ip) + mdns.register("rvoots-nameplate") end tmr.stop(0) joinCounter = nil