diff --git a/README.md b/README.md index 951f695..05fc6a2 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ server:start(function (request, response) end) ``` +Or try the [included examples](example/README.md). + ## Features - Compatible with Linux, Mac and Windows systems @@ -121,6 +123,25 @@ local server = Pegasus:new({ server:start() ``` + +* pegasus.plugins.files + +```lua +local Pegasus = require 'pegasus' +local Files = require 'pegasus.plugins.files' + +local server = Pegasus:new({ + plugins = { + Files:new { + location = "./", + default = "index.html", + }, + } +}) + +server:start() +``` + * pegasus.plugins.tls ```lua diff --git a/example/app.lua b/example/app.lua index f5a7112..2856077 100644 --- a/example/app.lua +++ b/example/app.lua @@ -10,11 +10,11 @@ package.path = './src/?.lua;./src/?/init.lua;' .. package.path local Pegasus = require 'pegasus' local Compress = require 'pegasus.plugins.compress' local Downloads = require 'pegasus.plugins.downloads' +local Files = require 'pegasus.plugins.files' -- local TLS = require 'pegasus.plugins.tls' local server = Pegasus:new({ port = '9090', - location = '/example/root/', plugins = { -- TLS:new { -- the tls specific configuration -- wrap = { @@ -30,19 +30,32 @@ local server = Pegasus:new({ -- }, Downloads:new { - prefix = "downloads", + location = '/example/root/', + prefix = 'downloads', stripPrefix = true, }, + Files:new { + location = '/example/root/', + }, + Compress:new(), } }) -server:start(function(req) - local data = req:post() +server:start(function(req, resp) + local stop = false + local path = req:path() + if req:method() ~= "POST" or path ~= "/index.html" then + return stop + end + + local data = req:post() if data then - print(data['name']) - print(data['age']) + print("Name: ", data.name) + print("Age: ", data.age) end + stop = not not resp:writeFile("./example/root" .. path) + return stop end) diff --git a/example/copas.lua b/example/copas.lua index f468fc4..586e128 100644 --- a/example/copas.lua +++ b/example/copas.lua @@ -20,7 +20,7 @@ local Downloads = require 'pegasus.plugins.downloads' -- @tparam[opt] table opts.sslparams the tls based parameters, see the Copas documentation. -- If not provided, then the connection will be accepted as a plain one. -- @tparam[opt] table opts.plugins the plugins to use --- @tparam[opt] function opts.handler the callback function to handle requests +-- @tparam[opt] function opts.callback the callback function to handle requests -- @tparam[opt] string opts.location the file-path from where to server files -- @return the server-socket on success, or nil+err on failure local function newPegasusServer(opts) diff --git a/rockspecs/pegasus-dev-1.rockspec b/rockspecs/pegasus-dev-1.rockspec index 9aa32b0..059ca40 100644 --- a/rockspecs/pegasus-dev-1.rockspec +++ b/rockspecs/pegasus-dev-1.rockspec @@ -39,6 +39,7 @@ build = { ['pegasus.compress'] = 'src/pegasus/compress.lua', ['pegasus.plugins.compress'] = 'src/pegasus/plugins/compress.lua', ['pegasus.plugins.downloads'] = 'src/pegasus/plugins/downloads.lua', + ['pegasus.plugins.files'] = 'src/pegasus/plugins/files.lua', ['pegasus.plugins.tls'] = 'src/pegasus/plugins/tls.lua', } } diff --git a/spec/integration/integration_spec.lua b/spec/integration/integration_spec.lua index 28b39e4..55f1a4f 100644 --- a/spec/integration/integration_spec.lua +++ b/spec/integration/integration_spec.lua @@ -1,6 +1,6 @@ describe('integration', function() local port = '7070' - local url = 'http://localhost:' .. port + local url = 'http://localhost:' .. port .. "/index.html" local executeCommand = function(command) local handle = assert(io.popen(command .. ' -s ' .. url)) diff --git a/spec/unit/files_spec.lua b/spec/unit/files_spec.lua new file mode 100644 index 0000000..2489c2b --- /dev/null +++ b/spec/unit/files_spec.lua @@ -0,0 +1,127 @@ +describe("Files plugin", function() + + local Files = require "pegasus.plugins.files" + + + + describe("instantiation", function() + + local options = {} + local plugin = Files:new(options) + + it("should return a table", function() + assert.is.table(plugin) + end) + + + it("should have a default location; '.'", function() + assert.is.equal(".", plugin.location) + end) + + + it("should have a default; '/index.html'", function() + assert.is.equal("/index.html", plugin.default) + end) + + end) + + + + describe("invocation", function() + + local redirect_called, writeFile_called + local request = {} + local response = { + redirect = function(self, ...) redirect_called = {...} end, + writeFile = function(self, ...) writeFile_called = {...} return self end, + -- finish = function(self, ...) end, + -- setHeader = function(self, ...) end, + -- setStatusCode = function(self, ...) end, + } + + before_each(function() + redirect_called = nil + writeFile_called = nil + end) + + + it("handles GET", function() + stub(request, "path", function() return "/some/file.html" end) + stub(request, "method", function() return "GET" end) + local stop = Files:new():newRequestResponse(request, response) + assert.is.True(stop) + assert.is.Nil(redirect_called) + assert.are.same({ + "./some/file.html", + "text/html" + }, writeFile_called) + end) + + it("handles HEAD", function() + stub(request, "path", function() return "/some/file.html" end) + stub(request, "method", function() return "HEAD" end) + local stop = Files:new():newRequestResponse(request, response) + assert.is.True(stop) + assert.is.Nil(redirect_called) + assert.are.same({ + "./some/file.html", + "text/html" + }, writeFile_called) + end) + + it("doesn't handle POST", function() + stub(request, "path", function() return "/some/file.html" end) + stub(request, "method", function() return "POST" end) + local stop = Files:new():newRequestResponse(request, response) + assert.is.False(stop) + assert.is.Nil(redirect_called) + assert.is.Nil(writeFile_called) + end) + + it("doesn't handle PUT", function() + stub(request, "path", function() return "/some/file.html" end) + stub(request, "method", function() return "PUT" end) + local stop = Files:new():newRequestResponse(request, response) + assert.is.False(stop) + assert.is.Nil(redirect_called) + assert.is.Nil(writeFile_called) + end) + + it("redirects GET /", function() + stub(request, "path", function() return "/" end) + stub(request, "method", function() return "GET" end) + local stop = Files:new():newRequestResponse(request, response) + assert.is.True(stop) + assert.are.same({ + "/index.html" + }, redirect_called) + assert.is.Nil(writeFile_called) + end) + + it("serves from specified location", function() + stub(request, "path", function() return "/some/file.html" end) + stub(request, "method", function() return "GET" end) + local stop = Files:new({ location = "./location" }):newRequestResponse(request, response) + assert.is.True(stop) + assert.is.Nil(redirect_called) + assert.are.same({ + "./location/some/file.html", + "text/html" + }, writeFile_called) + end) + + it("forces location to be relative", function() + stub(request, "path", function() return "/some/file.html" end) + stub(request, "method", function() return "GET" end) + local stop = Files:new({ location = "/location" }):newRequestResponse(request, response) + assert.is.True(stop) + assert.is.Nil(redirect_called) + assert.are.same({ + "./location/some/file.html", + "text/html" + }, writeFile_called) + end) + + end) + +end) diff --git a/src/pegasus/handler.lua b/src/pegasus/handler.lua index 1593756..c48b893 100644 --- a/src/pegasus/handler.lua +++ b/src/pegasus/handler.lua @@ -1,7 +1,6 @@ local Request = require 'pegasus.request' local Response = require 'pegasus.response' -local mimetypes = require 'mimetypes' -local lfs = require 'lfs' +local Files = require 'pegasus.plugins.files' local Handler = {} Handler.__index = Handler @@ -9,9 +8,15 @@ Handler.__index = Handler function Handler:new(callback, location, plugins) local handler = {} handler.callback = callback - handler.location = location or '' handler.plugins = plugins or {} + if location then + handler.plugins[#handler.plugins+1] = Files:new { + location = location, + default = "/index.html", + } + end + local result = setmetatable(handler, self) result:pluginsAlterRequestResponseMetatable() @@ -109,58 +114,32 @@ function Handler:processRequest(port, client, server) return false end - local request = Request:new(port, client, server) - if not request:method() then + local request = Request:new(port, client, server, self) + local response = request.response + + local method = request:method() + if not 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 = request:path() - if path == '/' or path == '' then - path = 'index.html' - end - 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') - stop = true - else - response:statusCode(404) - end - end - - if self.callback and not stop then + if self.callback then response:statusCode(200) response.headers = {} response:addHeader('Content-Type', 'text/html') - self.callback(request, response) + stop = self.callback(request, response) + if stop then + return + end end - if response.status == 404 then - response:writeDefaultErrorMessage(404) - end + response:writeDefaultErrorMessage(404) end diff --git a/src/pegasus/plugins/downloads.lua b/src/pegasus/plugins/downloads.lua index e5c8a63..ebe60ea 100644 --- a/src/pegasus/plugins/downloads.lua +++ b/src/pegasus/plugins/downloads.lua @@ -8,6 +8,7 @@ Downloads.__index = Downloads -- that triggers the plugin, but will be removed from the filepath if `stripPrefix` is truthy. -- If `stripPrefix` is falsy, then it should be a real folder. -- @tparam options table the options table with the following fields; +-- @tparam[opt="./"] options.location string the path to serve files from. Relative to the working directory. -- @tparam[opt="downloads/"] options.prefix string the path prefix that triggers the plugin -- @tparam options.stripPrefix bool whether to strip the prefix from the file path when looking -- for the file in the filesystem. Defaults to `false`, unless `options.prefix` is omitted, @@ -17,22 +18,36 @@ function Downloads:new(options) options = options or {} local plugin = {} - if not options.prefix then - plugin.prefix = "downloads/" + local location = options.location or "" + if location:sub(1,2) ~= "./" then + if location:sub(1,1) == "/" then + location = "." .. location + else + location = "./" .. location + end + end + if location:sub(-1,-1) == "/" then + location = location:sub(1, -2) + end + plugin.location = location -- this is now a relative path, without trailing slash + + local prefix = options.prefix + if prefix then + plugin.stripPrefix = not not options.stripPrefix + else + prefix = "downloads/" if options.stripPrefix == nil then plugin.stripPrefix = true else plugin.stripPrefix = not not options.stripPrefix end - else - plugin.prefix = options.prefix - plugin.stripPrefix = not not options.stripPrefix end - plugin.prefix = "/" .. plugin.prefix .. "/" - while plugin.prefix:find("//") do - plugin.prefix = plugin.prefix:gsub("//", "/") + prefix = "/" .. prefix .. "/" + while prefix:find("//") do + prefix = prefix:gsub("//", "/") end + plugin.prefix = prefix -- this is now the prefix, with pre+post fixed a / (or a single slash) setmetatable(plugin, Downloads) @@ -41,7 +56,9 @@ end function Downloads:newRequestResponse(request, response) local stop = false - if request:method() ~= "GET" then + + local method = request:method() + if method ~= "GET" and method ~= "HEAD" then return stop -- we only handle GET requests end @@ -50,13 +67,12 @@ function Downloads:newRequestResponse(request, response) return stop -- doesn't match our prefix end - local location = response._writeHandler.location or "" local filename = path if self.stripPrefix then - filename = path:sub(#self.prefix + 1, -1) + filename = path:sub(#self.prefix, -1) end - stop = not not response:sendFile('.' .. location .. filename) + stop = not not response:sendFile(self.location .. filename) return stop end diff --git a/src/pegasus/plugins/files.lua b/src/pegasus/plugins/files.lua new file mode 100644 index 0000000..c0a3eb2 --- /dev/null +++ b/src/pegasus/plugins/files.lua @@ -0,0 +1,73 @@ +--- A plugin that serves static content from a folder. + +local mimetypes = require 'mimetypes' + + +local Files = {} +Files.__index = Files + +--- Creates a new plugin instance. +-- The plugin will only respond to `GET` requests. The files will be served from the +-- `location` folder. +-- @tparam options table the options table with the following fields; +-- @tparam[opt="./"] options.location string the path to serve files from. Relative to the working directory. +-- @tparam[opt="index.html"] options.default string filename to serve for top-level without path. Use an empty +-- string to have none. +-- @return the new plugin +function Files:new(options) + options = options or {} + local plugin = {} + + local location = options.location or "" + if location:sub(1,2) ~= "./" then + -- make sure it's a relative path, forcefully! + if location:sub(1,1) == "/" then + location = "." .. location + else + location = "./" .. location + end + end + if location:sub(-1,-1) == "/" then + location = location:sub(1, -2) + end + plugin.location = location -- this is now a relative path, without trailing slash + + local default = options.default or "index.html" + if default ~= "" then + if default:sub(1,1) ~= "/" then + default = "/" .. default + end + end + plugin.default = default -- this is now a filename prefixed with a slash, or "" + + setmetatable(plugin, Files) + return plugin +end + + + +function Files:newRequestResponse(request, response) + local stop = false + + local method = request:method() + if method ~= "GET" and method ~= "HEAD" then + return stop -- we only handle GET requests + end + + local path = request:path() + if path == '/' then + if self.default ~= "" then + response:redirect(self.default) + stop = true + end + return stop -- no default set, so nothing to serve + end + + local filename = self.location .. path + + stop = not not response:writeFile(filename, mimetypes.guess(filename) or 'text/html') + + return stop +end + +return Files diff --git a/src/pegasus/request.lua b/src/pegasus/request.lua index d2da7a7..0582934 100644 --- a/src/pegasus/request.lua +++ b/src/pegasus/request.lua @@ -1,3 +1,5 @@ +local Response = require 'pegasus.response' + local function normalizePath(path) local value = string.gsub(path ,'\\', '/') value = string.gsub(value, '^/*', '/') @@ -36,7 +38,7 @@ Request.PATTERN_PATH ..Request.PATTERN_PROTOCOL) Request.PATTERN_QUERY_STRING = '([^=]*)=([^&]*)&?' Request.PATTERN_HEADER = '([%w-]+):[ \t]*([%w \t%p]*)' -function Request:new(port, client, server) +function Request:new(port, client, server, handler) local obj = {} obj.client = client obj.server = server @@ -51,6 +53,8 @@ function Request:new(port, client, server) obj._headers = {} obj._contentDone = 0 obj._contentLength = nil + obj.response = Response:new(client, handler) + obj.response.request = obj return setmetatable(obj, self) end @@ -75,6 +79,7 @@ function Request:parseFirstLine() self.client:close() return end + self.response:skipBody(method == "HEAD") print('Request for: ' .. path) diff --git a/src/pegasus/response.lua b/src/pegasus/response.lua index f9e8297..162f621 100644 --- a/src/pegasus/response.lua +++ b/src/pegasus/response.lua @@ -95,6 +95,7 @@ function Response:new(client, writeHandler) newObj._client = client newObj._writeHandler = writeHandler newObj.status = 200 + newObj._skipBody = false -- for HEAD requests return setmetatable(newObj, self) end @@ -126,6 +127,13 @@ function Response:statusCode(statusCode, statusText) return self end +function Response:skipBody(skip) + if skip == nil then + skip = true + end + self._skipBody = not not skip +end + function Response:_getHeaders() local headers = {} @@ -153,11 +161,14 @@ 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') + if not self._skipBody then + 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') end - self._client:send('0\r\n\r\n') self.close = true -- TODO: this seems unused?? return self @@ -200,12 +211,14 @@ function Response:write(body, stayOpen) self._isClosed = not stayOpen - if self._isClosed then - self._client:send(body) - elseif #body > 0 then - self._client:send( - toHex(#body) .. '\r\n' .. body .. '\r\n' - ) + if not self._skipBody then + if self._isClosed then + self._client:send(body) + elseif #body > 0 then + self._client:send( + toHex(#body) .. '\r\n' .. body .. '\r\n' + ) + end end if self._isClosed then