diff --git a/vrel.lua b/vrel.lua index aba99f5..ab20c84 100644 --- a/vrel.lua +++ b/vrel.lua @@ -3,13 +3,13 @@ -- This module requires LuaSocket 2.0.2, and debug mode requires LuaFileSystem 1.6.3. Install pygmentize for the optional syntax highlighting. If you want persistance for paste storage, install lsqlite3. vrel should work with Lua 5.1 to 5.3. math.randomseed(os.time()) local hasConfigFile, config = pcall(dofile, "config.lua") if not hasConfigFile then config = {} end --- Basic HTTP server -- +-- Basic HTTP 1.0 server -- local httpd, requestMaxDataSize = nil, config.requestMaxDataSize or 10485760 -- max post/paste data size (bytes) (10MB) httpd = { log = function(str, ...) print("["..os.date().."] "..str:format(...)) end, -- log a message (str:format(...)) peername = function(client) return ("%s:%s"):format(client:getpeername()) end, -- returns a nice display name for the client (address:port) unescape = function(txt) return require("socket.url").unescape(txt:gsub("+", " ")) end, -- unescape URL-encoded stuff - parseArgs = function(args) -- parse GET or POST arguments and returns the corresponding table {argName=argValue,...} (strings) + parseUrlEncoded = function(args) -- parse GET or POST arguments and returns the corresponding table {argName=argValue,...} (strings) local out = {} for arg in (args.."&"):gmatch("([^&]+)%&") do local name, value = arg:match("^(.*)%=(.*)$") @@ -22,7 +22,7 @@ httpd = { client = client, -- client object (tcp socket) method = "GET", -- HTTP method path = "/", -- requested path - version = "HTTP/1.1", -- HTTP version string + version = "HTTP/1.0", -- HTTP version string headers = {}, -- headers table: {headerName=headerValue,...} (strings) body = "", -- request body post = {}, -- POST args {argName=argValue,...} (strings) @@ -33,46 +33,49 @@ httpd = { local message = client:receive("*l") table.insert(lines, message) until not message or #message == 0 - -- Parse first line (method, path and HTTP version) - request.method, request.path, request.version = lines[1]:match("(%S*)%s(%S*)%s(%S*)") + request.method, request.path, request.version = lines[1]:match("(%S*)%s(%S*)%s(%S*)") -- Parse first line (method, path and HTTP version) if not request.method then return nil, "malformed request" end - -- Parse headers - for i=2, #lines, 1 do - local l = lines[i] - local name, value = l:match("^(.-)%:%s(.*)$") + for i=2, #lines, 1 do -- Parse headers + local name, value = lines[i]:match("^(.-)%:%s(.*)$") if name and value then request.headers[name] = value - elseif #l == 0 then break + elseif #lines[i] == 0 then break else return nil, "malformed headers" end end - if request.headers["Expect"] == "100-continue" then client:send("HTTP/1.1 100 Continue\r\n") client:receive("*l") end -- "Expect: 100-continue" basic support - -- Get body from socket - if request.headers["Content-Length"] then + if request.headers["Content-Length"] then -- Get body from socket if tonumber(request.headers["Content-Length"]) > requestMaxDataSize then return nil, ("body too big (>%sB)"):format(requestMaxDataSize) end -- size limitation request.body = client:receive(request.headers["Content-Length"]) - if request.method == "POST" then request.post = httpd.parseArgs(request.body) end -- POST args + if request.method == "POST" then -- POST args + if request.headers["Content-Type"]:match("multipart%/form%-data") then + local boundary = request.headers["Content-Type"]:match("multipart%/form%-data%; boundary%=([^;]+)"):gsub("%p", "%%%1") + for part in request.body:match("%-%-"..boundary.."(.*)"):gmatch("\r\n(.-)\r\n%-%-"..boundary) do + for l in part:gmatch("(.-)\r\n") do -- parse part headers + local name, value = l:match("^(.-)%:%s(.*)$") + if name == "Content-Disposition" then request.post[value:match("form%-data; name%=\"([^;\"]+)\"")] = part:match("\r\n\r\n(.*)") break + elseif name == nil or #l == 0 then break end + end + end + else request.post = httpd.parseUrlEncoded(request.body) end -- application/x-www-form-urlencoded + end end - request.get = httpd.parseArgs(require("socket.url").parse(request.path).query or "") -- Parse GET args + request.get = httpd.parseUrlEncoded(require("socket.url").parse(request.path).query or "") -- Parse GET args httpd.log("%s > %s", httpd.peername(client), lines[1]) -- Logging return request end, sendResponse = function(client, code, headers, body) -- send an HTTP response to a client - local text = "HTTP/1.1 "..code.."\r\n" -- First line + local text = "HTTP/1.0 "..code.."\r\n" -- First line for name, value in pairs(headers) do text = text..name..": "..value.."\r\n" end -- Add headers - text = text.."\r\n"..body -- Add body - httpd.log("%s < HTTP/1.1 %s", httpd.peername(client), code) -- Logging - client:send(text) + client:send(text.."\r\n"..body.."\r\n") -- Add body & send + httpd.log("%s < HTTP/1.0 %s", httpd.peername(client), code) -- Logging end, -- Start the server with the pages{pathMatch=function(request,captures)return{[cache=cacheDuration,]respCode,headers,body}end,pathMatch2={code,headers,body},...} and errorPages{404=sameAsPages,...} -- Optional table: options{debug=enable debug mode, timeout=client timeout in seconds before assuming he ran away (full sync server yeah), cacheCleanInterval = remove expired cache entries each interval of time (seconds)} start = function(address, port, pages, errorPages, options) options = options or { debug = false, timeout = 1, cacheCleanInterval = 3600 } - -- Start server local socket, url = require("socket"), require("socket.url") - local server, running = socket.bind(address, port), true + local server, running = socket.bind(address, port), true -- start server local cache, nextCacheClean = {}, os.time() + (options.cacheCleanInterval or 3600) httpd.log("HTTP server started on %s", ("%s:%s"):format(server:getsockname())) - -- Debug mode - if options.debug then + if options.debug then -- Debug mode httpd.log("Debug mode enabled") server:settimeout(1) -- Enable timeout (don't block forever so we can run debug code) local realServer = server @@ -88,14 +91,12 @@ httpd = { return realServer:accept(...) end end - -- Main loop - while running do + while running do -- Main loop local client = server:accept() -- blocks indefinitly (nothing else to do anyway) if client then httpd.log("Accepted connection from client %s", httpd.peername(client)) client:settimeout(options.timeout or 1) - -- Handle request - local success, err = xpcall(function() + local success, err = xpcall(function() -- Handle request local req, err = httpd.getRequest(client) if req then if cache[req.path] and cache[req.path].expire >= os.time() then httpd.sendResponse(client, unpack(cache[req.path].response)) return end @@ -105,7 +106,10 @@ httpd = { if shortPath:match("^"..path.."$") then -- strict match local response = type(page) == "table" and page or page(req, req.path:match("^"..path.."$")) if response then - if response.cache then cache[req.path] = { expire = os.time() + response.cache, response = response } end + if response.cache then -- put in cache + cache[req.path] = { expire = os.time() + response.cache, response = response } + response[2]["Expires"] = os.date("!%a, %d %b %Y %H:%M:%S GMT", cache[req.path].expire) + end httpd.sendResponse(client, unpack(response)) responded = true break @@ -128,7 +132,7 @@ httpd = { client:close() end local time = os.time() - if nextCacheClean < time then + if nextCacheClean < time then -- clean cache for path, req in pairs(cache) do if req.expire < time then cache[path] = nil end end nextCacheClean = time + (options.cacheCleanInterval or 3600) end @@ -181,10 +185,7 @@ local lastClean, cleanInterval = os.time(), config.cleanInterval or 1800 -- last local maxLifetime, defaultLifetime = config.maxLifetime or 15552000, config.defaultLifetime or 86400 -- maximum lifetime of a paste (6 month) and default (1 day) local function clean() -- clean the database each cleanInterval local time = os.time() - if lastClean + cleanInterval < time then - getmetatable(data).__clean(data, time) - lastClean = time - end + if lastClean + cleanInterval < time then getmetatable(data).__clean(data, time) lastClean = time end end local function get(name, request) clean() -- get a paste (returns nil if non-existent) (returned data is expected to be safe) if data[name] then @@ -248,9 +249,8 @@ httpd.start(config.address or "*", config.port or 8155, { -- Pages local name, paste = post({ lifetime = (tonumber(request.post.lifetime) or defaultLifetime)*(request.post.web and 3600 or 1), burnOnRead = request.post.burnOnRead == "on", syntax = (request.post.web and request.post.syntax == "" and "text") or request.post.syntax, data = request.post.data }, request) return request.post.web and { "303 See Other", {["Location"] = "/"..name}, "" } or - { "200 OK", {["Content-Type"] = "text/json; charset=utf-8"}, ([[{"name":"%s","lifetime":%s,"burnOnRead":%s,"syntax":"%s"}]]):format(name, paste.expire-os.time(), paste.burnOnRead,paste.syntax) } + { "200 OK", {["Content-Type"] = "text/json; charset=utf-8"}, ([[{"name":%q,"lifetime":%q,"burnOnRead":%q,"syntax":%q}]]):format(name, paste.expire-os.time(), paste.burnOnRead, paste.syntax) } end end -}, { -- Error pages - ["404"] = { "404", {["Content-Type"] = "text/json; charset=utf-8"}, [[{"error":"page not found"}]] }, ["500"] = { "500", {["Content-Type"] = "text/json; charset=utf-8"}, [[{"error":"internal server error"}]] } +}, { ["404"] = { "404", {["Content-Type"] = "text/json; charset=utf-8"}, [[{"error":"page not found"}]] }, ["500"] = { "500", {["Content-Type"] = "text/json; charset=utf-8"}, [[{"error":"internal server error"}]] } -- Error pages }, { timeout = config.timeout or 1, debug = config.debug or false, cacheCleanInterval = config.cacheCleanInterval or 3600 })