#!/bin/lua --- vrel v0.1.5: online paste service, in 256 lines of Lua (max line lenght = 256 but we shouldn't go this far if not needed). -- 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. local hasConfigFile, config = pcall(dofile, "config.lua") if not hasConfigFile then config = {} end -- Basic HTTP server -- local httpd, requestMaxDataSize = nil, config.requestMaxDataSize or 15728640 -- max post/paste data size (bytes) (15MB) 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) local out = {} for arg in (args.."&"):gmatch("([^&]+)%&") do local name, value = arg:match("^(.*)%=(.*)$") out[httpd.unescape(name)] = httpd.unescape(value) end return out end, getRequest = function(client) -- retrieve and parse an HTTP request, and returns the corresponding Request object (or nil,errorString) local request = { -- Request object client = client, -- client object (tcp socket) method = "GET", -- HTTP method path = "/", -- requested path version = "HTTP/1.1", -- HTTP version string headers = {}, -- headers table: {headerName=headerValue,...} (strings) body = "", -- request body post = {}, -- POST args {argName=argValue,...} (strings) get = {} -- GET args {argName=argValue,...} (strings) } -- Get headers data from socket local lines = {} repeat local message = client:receive("*l") table.insert(lines, message) until not message or #message == 0 -- Parse fisrt line (method, path and HTTP version) request.method, request.path, request.version = lines[1]:match("(%S*)%s(%S*)%s(%S*)") 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(.*)$") if name and value then request.headers[name] = value elseif #l == 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 tonumber(request.headers["Content-Length"]) > requestMaxDataSize then return nil, "body too big (>15Mo)" 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 end request.get = httpd.parseArgs(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 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) end, -- Start the server with the pages{pathMatch=function(request,captures)return{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)} start = function(address, port, pages, errorPages, options) options = options or { debug = false, timeout = 1 } -- Start server local socket, url = require("socket"), require("socket.url") local server, running = socket.bind(address, port), true httpd.log("HTTP server started on %s", ("%s:%s"):format(server:getsockname())) -- Debug mode if options.debug then httpd.log("Debug mode enabled") server:settimeout(1) -- Enable timeout (don't block forever so we can run debug code) local realServer = server server = setmetatable({}, {__index = function(_, k) return function(_, ...) return realServer[k](realServer, ...) end end}) -- Warp the server object so we can rewrite its functions -- Reload file on change local lfs = require("lfs") local lastModification = lfs.attributes(arg[0]).modification -- current last modification time function server:accept(...) if lfs.attributes(arg[0]).modification > lastModification then httpd.log("File changed, restarting server...\n----------------------------------------") running = false end return realServer:accept(...) end end -- Main loop while running do 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 req, err = httpd.getRequest(client) if req then local responded = false -- the request has been handled for path, page in pairs(pages) do local shortPath = url.parse(req.path).path -- path without GET arguments and stuff like that if shortPath:match("^"..path.."$") then -- strict match local response = type(page) == "table" and page or page(req, req.path:match("^"..path.."$")) if response then httpd.sendResponse(client, unpack(response)) responded = true break end end end if not responded then local page = errorPages["404"] or {"404", {}, "Page not found"} -- simple default 404 page httpd.sendResponse(client, unpack(type(page) == "table" and page or page(req))) end else httpd.log("%s - Invalid request: %s", httpd.peername(client), err) end end, function(error) return error..debug.traceback("", 2) end) -- add traceback to the error message if not success then httpd.log("Internal server error: %s", err) pcall(function() local page = errorPages["500"] or {"500", {}, "Internal server error"} -- simple default 500 page httpd.sendResponse(client, unpack(type(page) == "table" and page or page())) end) end client:close() end end server:close() if options.debug then os.execute((arg[-1] and (arg[-1].." ") or "")..arg[0].." "..table.concat(arg, " ")) end -- Restart server end } -- Vrel -- -- Load data local data = {} -- { ["name"] = { expire = os.time()+lifetime, burnOnRead = false, senderId = "someuniqueidentifier", syntax = "lua", data = "Hello\nWorld" } } local sqliteAvailable, sqlite3 = pcall(require, "lsqlite3") if sqliteAvailable then httpd.log("Using SQlite3 storage backend") -- SQlite backend local db = sqlite3.open("database.sqlite3") db:exec("CREATE TABLE IF NOT EXISTS data (name STRING PRIMARY KEY NOT NULL UNIQUE, expire INTEGER NOT NULL, burnOnRead INTEGER NOT NULL DEFAULT 0, senderId STRING NOT NULL, syntax STRING NOT NULL DEFAULT 'text', data STRING NOT NULL)") setmetatable(data, { __index = function(self, key) -- data[name]: get paste { expire = integer, burnOnRead = boolean, data = string } local stmt = db:prepare("SELECT expire, burnOnRead, senderId, syntax, data FROM data WHERE name = ?") stmt:bind_values(key) local r for row in stmt:nrows() do r = row r.burnOnRead = r.burnOnRead == 1 break end stmt:finalize() return r end, __newindex = function(self, key, value) if value ~= nil then -- data[name] = { expire = integer, burnOnRead = boolean, syntax = string, data = string }: add paste local stmt = db:prepare("INSERT INTO data VALUES (?, ?, ?, ?, ?, ?)") stmt:bind_values(key, value.expire, value.burnOnRead, value.senderId, value.syntax, value.data) stmt:step() stmt:finalize() else -- data[name] = nil: delete paste local stmt = db:prepare("DELETE FROM data WHERE name = ?") stmt:bind_values(key) stmt:step() stmt:finalize() end end, __clean = function(self, time) -- clean database local stmt = db:prepare("DELETE FROM data WHERE expire < ?") stmt:bind_values(time) stmt:step() stmt:finalize() end, __gc = function(self) db:close() end -- stop storage }) else httpd.log("Using in-memory storage backend") -- In-memory (table) backend setmetatable(data, { __clean = function(self, time) for name, d in pairs(self) do if d.expire < time then self[name] = nil end end end }) end -- Helpers functions local forbiddenName = { ["g"] = true, ["p"] = true } local function generateName(size) -- generate a paste name. If size ~= nil, will generate a random ID of this lenght. local name = "" repeat local charType, char = math.random() if charType < 10/62 then char = math.random(48, 57) -- numbers (10 possibilities out of 62) elseif charType < 36/62 then char = math.random(65, 90) -- upper letters (26 possibilities out of 62) else char = math.random(97, 122) end -- lower letters (26 possibilities out of 62) name = name..string.char(char) until (not size and not (data[name] or forbiddenName[name])) or (#name >= (size or math.huge)) return name end local lastClean, cleanInterval = os.time(), config.cleanInterval or 1800 -- last clean time (all time are stored in seconds) and clean interval (30min) 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 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 local d = data[name] if d.expire < os.time() then data[name] = nil return end if request.client:getpeername() ~= d.senderId and d.burnOnRead then data[name] = nil end -- burn on read (except if retrieved by original poster) return d end end local function post(paste, request) clean() -- add a paste, will check data and auto-fill defaults; returns name, paste data table local name = generateName() if paste.lifetime then paste.expire = os.time() + (tonumber(paste.lifetime) or defaultLifetime) end paste.expire = math.min(tonumber(paste.expire) or os.time()+defaultLifetime, os.time()+maxLifetime) paste.burnOnRead = paste.burnOnRead == true paste.senderId = paste.senderId or request.client:getpeername() or "0.0.0.0" paste.syntax = (paste.syntax or "text"):lower():match("[a-z]*") paste.data = tostring(paste.data) data[name] = paste return name, data[name] end local pygmentsStyle, extraStyle = "monokai", "*{color:#F8F8F2;background-color:#272822;margin:0px;}pre{color:#8D8D8A;}" -- pygments style name, extra css for highlighted blocks (also aply if no pygments) local function highlight(paste, forceLexer) -- Syntax highlighting; should returns the code block, style and everything included local source = assert(io.open("pygmentize.tmp", "w")) -- Lua can't at the same time write an read from a command, so we need to put one in a file source:write(paste.data) source:close() local pygments = assert(io.popen("pygmentize -f html -O linenos=table,style="..pygmentsStyle.." -l "..(forceLexer or paste.syntax).." pygmentize.tmp", "r")) local out = assert(pygments:read("*a")) pygments:close() if #out > 0 then -- if pygments available and available lexer (returned something) local style = assert(io.popen("pygmentize -f html -S "..pygmentsStyle, "r")) -- get style data out = out.."" style:close() return out -- no highlighter available, put in
 and escape
	else return "
"..paste.data:gsub("([\"&<>])",{["\""]=""",["&"]="&",["<"]="<",[">"]=">"}).."
" end end -- Start! httpd.start(config.address or "*", config.port or 8155, { -- Pages ["/([^/]*)"] = function(request, name) if forbiddenName[name] then return end if request.method == "POST" and request.post.data then name = post({ lifetime = (tonumber(request.post.lifetime) or defaultLifetime/3600)*3600, burnOnRead = request.post.burnOnRead == "on", data = request.post.data }, request) return { "303 See Other", {["Location"] = "/"..name}, "" } end return { "200 OK", {["Content-Type"] = "text/html"}, [[ vrel ]]..(#name == 0 and [[
expires in hours (burn on read) vrel
]] or highlight(get(name:match("^[^.]+"), request) or {data="paste not found"}, name:lower():match("%.([a-z]+)$")))..[[ ]] } end, ["/g/(.+)"] = function(request, name) local d = get(name, request) return d and { "200 OK", {["Content-Type"] = "text"}, d.data } or nil end, ["/p"] = function(request) if request.method == "POST" and request.post.data then local name, paste = post({ lifetime = tonumber(request.post.lifetime) or defaultLifetime, burnOnRead = request.post.burnOnRead == "on", data = request.post.data }, request) return { "200 OK", {["Content-Type"] = "text/json"}, "{\"name\":\""..name.."\",\"lifetime\":"..paste.expire-os.time()..",\"burnOnRead\":"..tostring(paste.burnOnRead).."}\n" } end end }, { -- Error pages ["404"] = { "404", {["Content-Type"] = "text/json"}, "{\"error\":\"page not found\"}\n" }, ["500"] = { "500", {["Content-Type"] = "text/json"}, "{\"error\":\"internal server error\"}\n" } }, { timeout = config.timeout or 1, debug = config.debug or false })