Initial commit

This commit is contained in:
mid
2024-06-01 17:40:11 +03:00
commit f4b0a0b531
36 changed files with 4027 additions and 0 deletions

3
pegasus/compress.lua Normal file
View File

@@ -0,0 +1,3 @@
-- compatibility shim for moving the plugin into a 'plugins' subfolder
-- without breaking anything
return require("pegasus.plugins.compress")

151
pegasus/handler.lua Normal file
View File

@@ -0,0 +1,151 @@
local Request = require 'pegasus.request'
local Response = require 'pegasus.response'
local mimetypes = require 'mimetypes'
local lfs = require 'lfs'
local function ternary(condition, t, f)
if condition then return t else return f end
end
local Handler = {}
Handler.__index = Handler
function Handler:new(callback, location, plugins)
local handler = {}
handler.callback = callback
handler.location = location or ''
handler.plugins = plugins or {}
local result = setmetatable(handler, self)
result:pluginsAlterRequestResponseMetatable()
return result
end
function Handler:pluginsAlterRequestResponseMetatable()
for _, plugin in ipairs(self.plugins) do
if plugin.alterRequestResponseMetaTable then
local stop = plugin:alterRequestResponseMetaTable(Request, Response)
if stop then
return stop
end
end
end
end
function Handler:pluginsNewRequestResponse(request, response)
for _, plugin in ipairs(self.plugins) do
if plugin.newRequestResponse then
local stop = plugin:newRequestResponse(request, response)
if stop then
return stop
end
end
end
end
function Handler:pluginsBeforeProcess(request, response)
for _, plugin in ipairs(self.plugins) do
if plugin.beforeProcess then
local stop = plugin:beforeProcess(request, response)
if stop then
return stop
end
end
end
end
function Handler:pluginsAfterProcess(request, response)
for _, plugin in ipairs(self.plugins) do
if plugin.afterProcess then
local stop = plugin:afterProcess(request, response)
if stop then
return stop
end
end
end
end
function Handler:pluginsProcessFile(request, response, filename)
for _, plugin in ipairs(self.plugins) do
if plugin.processFile then
local stop = plugin:processFile(request, response, filename)
if stop then
return stop
end
end
end
end
function Handler:processBodyData(data, stayOpen, response)
local localData = data
for _, plugin in ipairs(self.plugins or {}) do
if plugin.processBodyData then
localData = plugin:processBodyData(
localData,
stayOpen,
response.request,
response
)
end
end
return localData
end
function Handler:processRequest(port, client, server)
local request = Request:new(port, client, server)
if not request:method() then
client:close()
return
end
local response = Response:new(client, self)
response.request = request
local stop = self:pluginsNewRequestResponse(request, response)
if stop then
return
end
if request:path() and self.location ~= '' then
local path = ternary(request:path() == '/' or request:path() == '', 'index.html', request:path())
local filename = '.' .. self.location .. path
if not lfs.attributes(filename) then
response:statusCode(404)
end
stop = self:pluginsProcessFile(request, response, filename)
if stop then
return
end
local file = io.open(filename, 'rb')
if file then
response:writeFile(file, mimetypes.guess(filename or '') or 'text/html')
else
response:statusCode(404)
end
end
if self.callback then
response:statusCode(200)
response.headers = {}
response:addHeader('Content-Type', 'text/html')
self.callback(request, response)
end
if response.status == 404 and not response._isClosed then
response:writeDefaultErrorMessage(404)
end
end
return Handler

63
pegasus/init.lua Normal file
View File

@@ -0,0 +1,63 @@
local socket = require 'socket'
local Handler = require 'pegasus.handler'
local FCNTL = require 'posix.fcntl'
local time = require 'posix.time'
local Pegasus = {}
Pegasus.__index = Pegasus
function Pegasus:new(params)
params = params or {}
local server = {}
server.host = params.host or '*'
server.port = params.port or '9090'
server.location = params.location or ''
server.plugins = params.plugins or {}
server.timeout = params.timeout or 1
server.threads = {}
return setmetatable(server, self)
end
function Pegasus:start(callback)
local handler = Handler:new(callback, self.location, self.plugins)
local server = assert(socket.bind(self.host, self.port))
local ip, port = server:getsockname()
print('Pegasus is up on ' .. ip .. ":".. port)
FCNTL.fcntl(server:getfd(), FCNTL.F_SETFD, FCNTL.FD_CLOEXEC)
--server:settimeout(0)
local i = 1
while 1 do
local client, errmsg = server:accept()
if client then
FCNTL.fcntl(client:getfd(), FCNTL.F_SETFD, FCNTL.FD_CLOEXEC)
client:settimeout(self.timeout, 'b')
--local coro = coroutine.create(handler.processRequest)
--coroutine.resume(coro, handler, self.port, client, server)
--if coroutine.status(coro) ~= "dead" then
-- self.threads[#self.threads + 1] = coro
--end
handler:processRequest(self.port, client, server)
elseif errmsg ~= 'timeout' then
io.stderr:write('Failed to accept connection:' .. errmsg .. '\n')
end
--[[if #self.threads > 0 then
coroutine.resume(self.threads[i])
if coroutine.status(self.threads[i]) == "dead" then
table.remove(self.threads, i)
else
i = i % #self.threads + 1
end
else
time.nanosleep({tv_sec = 0, tv_nsec = 500000})
end]]
end
end
return Pegasus

View File

@@ -0,0 +1,145 @@
local zlib = require "zlib"
local function zlib_name(lib)
if lib._VERSION and string.find(lib._VERSION, 'lua-zlib', nil, true) then
return 'lua-zlib'
end
if lib._VERSION and string.find(lib._VERSION, 'lzlib', nil, true) then
return 'lzlib'
end
end
local z_lib_name = assert(zlib_name(zlib), 'Unsupported zlib Lua binding')
local ZlibStream = {} do
ZlibStream.__index = ZlibStream
ZlibStream.NO_COMPRESSION = zlib.NO_COMPRESSION or 0
ZlibStream.BEST_SPEED = zlib.BEST_SPEED or 1
ZlibStream.BEST_COMPRESSION = zlib.BEST_COMPRESSION or 9
ZlibStream.DEFAULT_COMPRESSION = zlib.DEFAULT_COMPRESSION or -1
ZlibStream.STORE = 0
ZlibStream.DEFLATE = 8
if z_lib_name == 'lzlib' then
function ZlibStream:new(writer, level, method, windowBits)
level = level or ZlibStream.DEFAULT_COMPRESSION
method = method or ZlibStream.DEFLATE
local o = setmetatable({
zd = assert(zlib.deflate(writer, level, method, windowBits));
}, self)
return o
end
function ZlibStream:write(chunk)
assert(self.zd:write(chunk))
end
function ZlibStream:close()
self.zd:close()
end
elseif z_lib_name == 'lua-zlib' then
function ZlibStream:new(writer, level, method, windowBits)
level = level or ZlibStream.DEFAULT_COMPRESSION
method = method or ZlibStream.DEFLATE
assert(method == ZlibStream.DEFLATE, 'lua-zlib support only deflated method')
local o = setmetatable({
zd = assert(zlib.deflate(level, windowBits));
writer = writer;
}, self)
return o
end
function ZlibStream:write(chunk)
chunk = assert(self.zd(chunk))
self.writer(chunk)
end
function ZlibStream:close()
local chunk = self.zd('', 'finish')
if chunk and #chunk > 0 then self.writer(chunk) end
end
end
end
local Compress = {} do
Compress.__index = Compress
Compress.NO_COMPRESSION = ZlibStream.NO_COMPRESSION
Compress.BEST_SPEED = ZlibStream.BEST_SPEED
Compress.BEST_COMPRESSION = ZlibStream.BEST_COMPRESSION
Compress.DEFAULT_COMPRESSION = ZlibStream.DEFAULT_COMPRESSION
function Compress:new(options)
local compress = {}
compress.options = options or {}
return setmetatable(compress, self)
end
function Compress:processBodyData(data, stayOpen, request, response)
local accept_encoding
if response.headersSended then
accept_encoding = response.headers['Content-Encoding'] or ''
else
local headers = request:headers()
accept_encoding = headers and headers['Accept-Encoding'] or ''
end
local accept_gzip = not not accept_encoding:find('gzip', nil, true)
if accept_gzip and self.options.level ~= ZlibStream.NO_COMPRESSION then
local stream = response.compress_stream
local buffer = response.compress_buffer
if not stream then
local writer = function (zdata) buffer[#buffer + 1] = zdata end
stream, buffer = ZlibStream:new(writer, self.options.level, nil, 31), {}
end
if stayOpen then
if data == nil then
stream:close()
response.compress_stream = nil
response.compress_buffer = nil
else
stream:write(data)
response.compress_stream = stream
response.compress_buffer = buffer
end
local compressed = table.concat(buffer)
for i = 1, #buffer do buffer[i] = nil end
if not response.headersSended then
response:addHeader('Content-Encoding', 'gzip')
end
return compressed
end
stream:write(data)
stream:close()
local compressed = table.concat(buffer)
if #compressed < #data then
if not response.headersSended then
response:addHeader('Content-Encoding', 'gzip')
end
return compressed
end
end
return data
end
end
return Compress

277
pegasus/request.lua Normal file
View File

@@ -0,0 +1,277 @@
local function normalizePath(path)
local value = string.gsub(path ,'\\', '/')
value = string.gsub(value, '^/*', '/')
value = string.gsub(value, '(/%.%.?)$', '%1/')
value = string.gsub(value, '/%./', '/')
value = string.gsub(value, '/+', '/')
while true do
local first, last = string.find(value, '/[^/]+/%.%./')
if not first then break end
value = string.sub(value, 1, first) .. string.sub(value, last + 1)
end
while true do
local n
value, n = string.gsub(value, '^/%.%.?/', '/')
if n == 0 then break end
end
while true do
local n
value, n = string.gsub(value, '/%.%.?$', '/')
if n == 0 then break end
end
return value
end
local Request = {}
Request.__index = Request
Request.PATTERN_METHOD = '^(.-)%s'
Request.PATTERN_PATH = '(%S+)%s*'
Request.PATTERN_PROTOCOL = '(HTTP%/%d%.%d)'
Request.PATTERN_REQUEST = (Request.PATTERN_METHOD ..
Request.PATTERN_PATH ..Request.PATTERN_PROTOCOL)
Request.PATTERN_QUERY_STRING = '([^=]*)=([^&]*)&?'
Request.PATTERN_HEADER = '([%w-]+): ([%w %p]+=?)'
function Request:new(port, client, server)
local obj = {}
obj.client = client
obj.server = server
obj.port = port
obj.ip = client:getpeername()
obj.querystring = {}
obj._firstLine = nil
obj._method = nil
obj._path = nil
obj._params = {}
obj._headerParsed = false
obj._headers = {}
obj._contentDone = 0
obj._contentLength = nil
return setmetatable(obj, self)
end
function Request:parseFirstLine()
if (self._firstLine ~= nil) then
return
end
local status, partial
self._firstLine, status, partial = self.client:receive()
if (self._firstLine == nil or status == 'timeout' or partial == '' or status == 'closed') then
return
end
-- Parse firstline http: METHOD PATH
-- GET Makefile HTTP/1.1
local method, path = string.match(self._firstLine, Request.PATTERN_REQUEST)
if not method then
self.client:close()
return
end
print(os.date() .. ";" .. self.client:getpeername() .. ';' .. method .. ";" .. path)
local filename = ''
local querystring = ''
if #path then
filename, querystring = string.match(path, '^([^#?]+)[#|?]?(.*)')
filename = normalizePath(filename)
end
self._path = filename
self._method = method
self.querystring = self:parseUrlEncoded(querystring)
end
function Request:parseUrlEncoded(data)
local output = {}
if data then
for key, value in string.gmatch(data, Request.PATTERN_QUERY_STRING) do
if key and value then
local v = output[key]
if not v then
output[key] = value
elseif type(v) == "string" then
output[key] = { v, value }
else -- v is a table
v[#v + 1] = value
end
end
end
end
return output
end
function Request:parseMultipartFormData(data)
local boundary = self:headers()["content-type"]:match("multipart/form%-data;%s+boundary=([^,]+)")
local output = {}
while true do
data = data:sub(data:find("--" .. boundary) + 2 + #boundary)
if data:match("^--%s+$") then
break
end
local name, filename, filetype
local metadataHeaders = data:sub(1, data:find("\r\n\r\n", 5) + 1)
for line in metadataHeaders:gmatch("([^\r]+)\r\n") do
local key, value = line:match(Request.PATTERN_HEADER)
if not key then
break
elseif key:lower() == "content-disposition" then
-- TODO: secure?
name = value:match("; name=\"([^\"]+)\"")
filename = value:match("; filename=\"([^\"]+)\"")
elseif key:lower() == "content-type" then
filetype = value
end
end
local itemdata = data:sub(data:find"\r\n\r\n" + 4, data:find("\r\n--" .. boundary) - 1)
if name then
if filename then
-- Files *must* have a type, else this ignores them
if filetype then
output[name] = {
data = itemdata,
filename = filename,
type = filetype
}
end
else
output[name] = itemdata
end
end
end
return output
end
function Request:post()
if self:method() ~= 'POST' then return nil end
if self._postdata then return self._postdata end
local ct = self:headers()["content-type"]
local data = self:receiveBody()
if ct:sub(1, ct:find";") == "multipart/form-data;" then
self._postdata = self:parseMultipartFormData(data)
else
self._postdata = self:parseUrlEncoded(data)
end
return self._postdata
end
function Request:path()
self:parseFirstLine()
return self._path
end
function Request:method()
self:parseFirstLine()
return self._method
end
function Request:headers()
if self._headerParsed then
return self._headers
end
self:parseFirstLine()
local data = self.client:receive()
local headers = setmetatable({},{ -- add metatable to do case-insensitive lookup
__index = function(self, key)
if type(key) == "string" then
key = key:lower()
return rawget(self, key)
end
end
})
while (data ~= nil) and (data:len() > 0) do
local key, value = string.match(data, Request.PATTERN_HEADER)
if key and value then
key = key:lower()
local v = headers[key]
if not v then
headers[key] = value
elseif type(v) == "string" then
headers[key] = { v, value }
else -- t == "table", v is a table
v[#v + 1] = value
end
end
data = self.client:receive()
end
self._headerParsed = true
self._contentLength = tonumber(headers["content-length"] or 0)
self._headers = headers
return headers
end
function Request:cookie(name)
if not self:headers()["cookie"] then
return nil
end
return self:headers()["cookie"]:match(name:gsub("([^%w])", "%%%1") .. "=([^%;]*)")
end
function Request:receiveBody(size)
-- do we have content?
if (self._contentLength == nil) or (self._contentDone >= self._contentLength) then
return false
end
--[[local leftToLoad = self._contentLength
local ret = {}
local maxEmpties = 15
while leftToLoad > 0 do
local chunkSz = math.min(512 * 1024, leftToLoad)
local data, err, partial = self.client:receive(chunkSz)
if err == 'timeout' then
data = partial
end
table.insert(ret, data)
leftToLoad = leftToLoad - #data
if leftToLoad > 0 then
coroutine.yield()
end
end
return table.concat(ret)]]
local data, err, partial = self.client:receive(self._contentLength)
if err == 'timeout' then
data = partial
end
self._contentDone = self._contentLength
return data
end
return Request

238
pegasus/response.lua Normal file
View File

@@ -0,0 +1,238 @@
local function toHex(dec)
local charset = { '0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f' }
local tmp = {}
repeat
table.insert(tmp, 1, charset[dec % 16 + 1])
dec = math.floor(dec / 16)
until dec == 0
return table.concat(tmp)
end
local STATUS_TEXT = setmetatable({
[100] = 'Continue',
[101] = 'Switching Protocols',
[200] = 'OK',
[201] = 'Created',
[202] = 'Accepted',
[203] = 'Non-Authoritative Information',
[204] = 'No Content',
[205] = 'Reset Content',
[206] = 'Partial Content',
[300] = 'Multiple Choices',
[301] = 'Moved Permanently',
[302] = 'Found',
[303] = 'See Other',
[304] = 'Not Modified',
[305] = 'Use Proxy',
[307] = 'Temporary Redirect',
[400] = 'Bad Request',
[401] = 'Unauthorized',
[402] = 'Payment Required',
[403] = 'Forbidden',
[404] = 'Not Found',
[405] = 'Method Not Allowed',
[406] = 'Not Acceptable',
[407] = 'Proxy Authentication Required',
[408] = 'Request Time-out',
[409] = 'Conflict',
[410] = 'Gone',
[411] = 'Length Required',
[412] = 'Precondition Failed',
[413] = 'Request Entity Too Large',
[414] = 'Request-URI Too Large',
[415] = 'Unsupported Media Type',
[416] = 'Requested range not satisfiable',
[417] = 'Expectation Failed',
[500] = 'Internal Server Error',
[501] = 'Not Implemented',
[502] = 'Bad Gateway',
[503] = 'Service Unavailable',
[504] = 'Gateway Time-out',
[505] = 'HTTP Version not supported',
}, {
__index = function(self, statusCode)
-- if the lookup failed, try coerce to a number and try again
if type(statusCode) == "string" then
local result = rawget(self, tonumber(statusCode) or -1)
if result then
return result
end
end
error("http status code '"..tostring(statusCode).."' is unknown", 2)
end,
})
local DEFAULT_ERROR_MESSAGE = [[
<!DOCTYPE HTML PUBLIC '-//W3C//DTD HTML 4.01//EN'
'http://www.w3.org/TR/html4/strict.dtd'>
<html>
<head>
<meta http-equiv='Content-Type' content='text/html;charset=utf-8'>
<title>Error response</title>
</head>
<body>
<h1>Error response</h1>
<p>Error code: {{ STATUS_CODE }}</p>
<p>Message: {{ STATUS_TEXT }}</p>
</body>
</html>
]]
local Response = {}
Response.__index = Response
function Response:new(client, writeHandler)
local newObj = {}
newObj._headersSended = false
newObj._templateFirstLine = 'HTTP/1.1 {{ STATUS_CODE }} {{ STATUS_TEXT }}\r\n'
newObj._headFirstLine = ''
newObj._headers = {}
newObj._isClosed = false
newObj._client = client
newObj._writeHandler = writeHandler
newObj.status = 200
return setmetatable(newObj, self)
end
function Response:addHeader(key, value)
assert(not self._headersSended, "can't add header, they were already sent")
self._headers[key] = value
return self
end
function Response:addHeaders(params)
for key, value in pairs(params) do
self:addHeader(key, value)
end
return self
end
function Response:contentType(value)
return self:addHeader('Content-Type', value)
end
function Response:statusCode(statusCode, statusText)
assert(not self._headersSended, "can't set status code, it was already sent")
self.status = statusCode
self._headFirstLine = string.gsub(self._templateFirstLine, '{{ STATUS_CODE }}', tostring(statusCode))
self._headFirstLine = string.gsub(self._headFirstLine, '{{ STATUS_TEXT }}', statusText or STATUS_TEXT[statusCode])
return self
end
function Response:_getHeaders()
local headers = {}
for header_name, header_value in pairs(self._headers) do
if type(header_value) == "table" and #header_value > 0 then
for _, sub_value in ipairs(header_value) do
headers[#headers + 1] = header_name .. ': ' .. sub_value .. '\r\n'
end
else
headers[#headers + 1] = header_name .. ': ' .. header_value .. '\r\n'
end
end
return table.concat(headers)
end
function Response:writeDefaultErrorMessage(statusCode)
self:statusCode(statusCode)
local content = string.gsub(DEFAULT_ERROR_MESSAGE, '{{ STATUS_CODE }}', statusCode)
self:write(string.gsub(content, '{{ STATUS_TEXT }}', STATUS_TEXT[statusCode]), false)
return self
end
function Response:close()
local body = self._writeHandler:processBodyData(nil, true, self)
if body and #body > 0 then
self._client:send(toHex(#body) .. '\r\n' .. body .. '\r\n')
end
self._client:send('0\r\n\r\n')
self.close = true -- TODO: this seems unused??
return self
end
function Response:sendOnlyHeaders()
self:sendHeaders(false, '')
self:write('\r\n')
return self
end
function Response:sendHeaders(stayOpen, body)
if self._headersSended then
return self
end
if stayOpen then
self:addHeader('Transfer-Encoding', 'chunked')
elseif type(body) == 'string' then
self:addHeader('Content-Length', body:len())
end
self:addHeader('Date', os.date('!%a, %d %b %Y %H:%M:%S GMT', os.time()))
if not self._headers['Content-Type'] then
self:addHeader('Content-Type', 'text/html')
end
self._headersSended = true
self._client:send(self._headFirstLine .. self:_getHeaders() .. '\r\n')
self._chunked = stayOpen
return self
end
function Response:sendneck(data)
--if #data <= 512*1024 then
self._client:send(data)
--[[else
for i = 1, #data, 512*1024 do
self._client:send(data:sub(i, math.min(i + 512*1024 - 1, #data)))
coroutine.yield()
end
end]]
end
function Response:write(body, stayOpen)
body = self._writeHandler:processBodyData(body or '', stayOpen, self)
self:sendHeaders(stayOpen, body)
self._isClosed = not stayOpen
if self._isClosed then
self:sendneck(body)
elseif #body > 0 then
self:sendneck(toHex(#body) .. '\r\n' .. body .. '\r\n')
end
if self._isClosed then
self._client:close() -- TODO: remove this, a non-chunked body can also be sent in multiple pieces
if self.status // 100 > 3 then
print("Not OK: " .. self.status)
end
end
return self
end
function Response:writeFile(file, contentType)
self:contentType(contentType)
self:statusCode(200)
local value = file:read('*a')
file:close()
self:write(value)
return self
end
return Response