-------------------------------------------------------------------------------- -- Title: HTTP.lua -- Description: Like a square peg in a round hole -- Author: Raphaël Szwarc http://alt.textdrive.com/lua/ -- Creation Date: January 30, 2007 -- Legal: Copyright (C) 2007 Raphaël Szwarc -- Under the terms of the MIT License -- http://www.opensource.org/licenses/mit-license.html -------------------------------------------------------------------------------- -- import dependencies local debug = require( 'debug' ) local io = require( 'io' ) local os = require( 'os' ) local table = require( 'table' ) -------------------------------------------------------------------------------- -- HTTP.URL -- Based on Diego Nehab's LuaSocket URL: -- http://www.cs.princeton.edu/~diego/professional/luasocket/url.html -------------------------------------------------------------------------------- module( 'HTTP.URL', package.seeall ) _DESCRIPTION = 'HTTP.URL' _VERSION = '1.0' local self = _M local meta = getmetatable( self ) local function ReadURL( aURL ) local someComponents = {} -- fragment aURL = aURL:gsub( '#(.*)$', function( aValue ) someComponents.fragment = aValue return '' end ) -- scheme aURL = aURL:gsub( '^([%w][%w%+%-%.]*)%:', function( aValue ) someComponents.scheme = aValue:lower() return '' end ) -- authority aURL = aURL:gsub( '^//([^/]*)', function( aValue ) someComponents.authority = aValue return '' end ) -- query aURL = aURL:gsub( '%?(.*)', function( aValue ) someComponents.query = aValue return '' end ) -- parameter aURL = aURL:gsub( '%;(.*)', function( aValue ) someComponents.parameter = aValue return '' end ) -- path if aURL ~= '' then someComponents.path = aURL end if someComponents.authority then local anAuthority = someComponents.authority -- user info anAuthority = anAuthority:gsub( '^([^@]*)@', function( aValue ) someComponents.userInfo = aValue return '' end ) -- port anAuthority = anAuthority:gsub( ':([^:]*)$', function( aValue ) someComponents.port = tonumber( aValue ) return '' end ) -- host if anAuthority ~= '' then someComponents.host = anAuthority:lower() end if someComponents.userInfo then local anUserInfo = someComponents.userInfo -- password anUserInfo = anUserInfo:gsub( ':([^:]*)$', function( aValue ) someComponents.password = aValue return '' end ) -- user someComponents.user = anUserInfo end end return someComponents end local function WriteURL( someComponents ) local aURL = '' local aPath = someComponents.path local aParameter = someComponents.parameter local aQuery = someComponents.query local anAuthority = someComponents.authority local anHost = someComponents.host local aScheme = someComponents.scheme local aFragment = someComponents.fragment if aPath then aURL = aURL .. aPath end if aParameter then aURL = aURL .. ';' .. aParameter end if aQuery then aURL = aURL .. '?' .. aQuery end if anHost then local aPort = someComponents.port local anUserInfo = someComponents.userInfo local anUser = someComponents.user anAuthority = anHost if aPort then anAuthority = anAuthority .. ':' .. aPort end if anUser then local aPassword = someComponents.password anUserInfo = anUser if aPassword then anUserInfo = anUserInfo .. ':' .. aPassword end end if anUserInfo then anAuthority = anUserInfo .. '@' .. anAuthority end if anAuthority then aURL = '//' .. anAuthority .. aURL end end if aScheme then aURL = aScheme .. ':' .. aURL end if aFragment then aURL = aURL .. '#' .. aFragment end return aURL end function meta:__call( aValue ) local aURL = nil if type( aValue ) == 'table' then aURL = ReadURL( WriteURL( aValue ) ) else aURL = ReadURL( tostring( aValue ) ) end setmetatable( aURL, self ) self.__index = self return aURL end function self:__tostring() return WriteURL( self ) end -------------------------------------------------------------------------------- -- HTTP.Cookie -- as per Xavante's Cookies module -- http://www.keplerproject.org/xavante/ -------------------------------------------------------------------------------- module( 'HTTP.Cookie', package.seeall ) _DESCRIPTION = 'HTTP.Cookie' _VERSION = '1.0' local self = _M local meta = getmetatable( self ) local function ReadCookie( aValue ) local someCookies = {} local aName = nil for aKey, aValue in ( aValue or '' ):gmatch( '([^%s;=]+)%s*=%s*"([^"]*)"' ) do aKey = aKey:lower() if aKey:byte() == 36 then -- $option if aName then local anOption = aKey:sub( 2 ) someCookies[ aName ].options[ anOption ] = aValue end else someCookies[ aKey ] = { value = aValue, options = {} } aName = aKey end end return someCookies end local function WriteCookie( aValue ) local aBuffer = {} for aName, aCookie in pairs( aValue or {} ) do local aFormat = ( '%s="%s";version="1"' ):format( aName, aCookie.value ) local someOptions = aCookie.options if someOptions then for aKey, aValue in pairs( someOptions ) do aFormat = aFormat .. ( ';%s="%s"' ):format( aKey:lower(), tostring( aValue ) ) end end aBuffer[ #aBuffer + 1 ] = aFormat end if #aBuffer > 0 then return table.concat( aBuffer, '' ) end return nil end function meta:__call( aValue ) local aCookie = nil if type( aValue ) == 'table' then aCookie = ReadCookie( WriteCookie( aValue ) ) else aCookie = ReadCookie( tostring( aValue or '' ) ) end setmetatable( aCookie, self ) self.__index = self return aCookie end function self:__tostring() return WriteCookie( self ) end -------------------------------------------------------------------------------- -- HTTP.Request -------------------------------------------------------------------------------- module( 'HTTP.Request', package.seeall ) _DESCRIPTION = 'HTTP.Request' _VERSION = '1.0' local self = _M local meta = getmetatable( self ) function AuthorizationFilter( aRequest, aResponse ) local aHeader = aRequest.header[ 'authorization' ] aRequest.authorization = {} aResponse.authorization = { type = 'Basic' } if aHeader then local aScheme, aCredential = aHeader:match( '(%S+)%s(%S+)' ) if aScheme and aCredential and aScheme:find( 'Basic' ) then -- Requires Luiz Henrique de Figueiredo's lbase64 library -- http://www.tecgraf.puc-rio.br/~lhf/ftp/lua/5.0/lbase64.tar.gz local ok, _ = pcall( require, 'base64' ) if ok and base64 then local aUser, aPassword = base64.decode( aCredential ):match( '(.+):(.+)' ) local anAuthorization = { scheme = aScheme, user = aUser, password = aPassword } aRequest.authorization = anAuthorization end end end end function CookieFilter( aRequest, aResponse ) local aHeader = aRequest.header[ 'cookie' ] local aCookie = HTTP.Cookie( aHeader ) aRequest.cookie = aCookie aResponse.cookie = HTTP.Cookie( aCookie ) end function ParameterFilter( aRequest, aResponse ) local aType = ( aRequest.header[ 'content-type' ] or '' ):lower() if aType:find( 'multipart/form-data', 1, true ) then else aType:find( 'application/x-www-form-urlencoded', 1, true ) end end function URLFilter( aRequest, aResponse ) local aURI = aRequest.line.uri or '/' local aURL = HTTP.URL( aURI ) if not aURL.host then local aHost = aRequest.header[ 'host' ] or 'localhost' local aSchema = 'http' aURL = HTTP.URL( aSchema .. '://' .. aHost .. aURI ) end aRequest.url = aURL end self.filter = { AuthorizationFilter, CookieFilter, ParameterFilter, URLFilter } local function ReadLine( aReader ) local aLine = aReader:read() local aMethod, aURI, aVersion = ( aLine or '' ):match( '(%S+)%s(%S+)%s(%S+)' ) local aTable = { method = aMethod, uri = aURI, version = aVersion } return aTable end local function ReadHeader( aReader ) local aHeader = {} for aLine in aReader:lines() do local aKey, aValue = aLine:match( '(%S-): (.*)' ) if aKey then local aKey = aKey:lower() local aPreviousValue = aHeader[ aKey ] if aPreviousValue then aValue = aValue .. ',' .. aPreviousValue end aHeader[ aKey ] = aValue else break end end return aHeader end local function ReadChunkedContent( aReader ) local aBuffer = {} local aLine = aReader:read() local aSize = tonumber( aLine, 16 ) while aSize and aSize > 0 do aBuffer[ #aBuffer + 1 ] = aReader:read( aSize ) aReader:read() aLine = aReader:read() aSize = tonumber( aLine, 16 ) end aLine = aReader:read() while aLine and aLine ~= '' do aLine = aReader:read() end return table.concat( aBuffer ) end local function ReadContent( aReader, aHeader ) local anEncoding = aHeader[ 'transfer-encoding' ] or '' local aLength = tonumber( aHeader[ 'content-length' ] ) or 0 if anEncoding:find( 'chunked', 1, true ) then return ReadChunkedContent( aReader ) end if aLength > 0 then return aReader:read( aLength ) end return nil end function meta:__call( aReader ) local aLine = ReadLine( aReader ) local aHeader = ReadHeader( aReader ) local aContent = ReadContent( aReader, aHeader ) local aRequest = { line = aLine, header = aHeader, content = aContent } setmetatable( aRequest, self ) self.__index = self return aRequest end function self:__tostring() local aBuffer = {} local aLine = self.line or {} local aMethod = tostring( aLine.method or 'GET' ) local aURI = tostring( aLine.uri or '/' ) local aVersion = tostring( aLine.version or 'HTTP/1.1' ) local aHeader = self.header or {} local aContent = self.content or '' aBuffer[ #aBuffer + 1 ] = ( '%s %s %s' ):format( aMethod, aURI, aVersion ) for aKey, aValue in pairs( aHeader ) do aBuffer[ #aBuffer + 1 ] = ( '%s: %s' ):format( tostring( aKey ), tostring( aValue ) ) end aBuffer[ #aBuffer + 1 ] = '' aBuffer[ #aBuffer + 1 ] = tostring( aContent ) return table.concat( aBuffer, '\r\n' ) end -------------------------------------------------------------------------------- -- HTTP.Response -------------------------------------------------------------------------------- module( 'HTTP.Response', package.seeall ) _DESCRIPTION = 'HTTP.Response' _VERSION = '1.0' local self = _M local meta = getmetatable( self ) function AuthorizationFilter( aRequest, aResponse ) local anAuthorization = aResponse.authorization or {} local aScheme = anAuthorization.scheme local aRealm = anAuthorization.realm if aScheme and aScheme == 'Basic' and aRealm then aResponse.status = 401 aResponse.status.description = 'Unauthorised' aResponse.header[ 'www-authenticate' ] = ( '%s realm="%s"' ):format( aScheme, aRealm ) end end function ConnectionFilter( aRequest, aResponse ) local aConnection = aRequest.header[ 'connection' ] if aConnection then aConnection = aConnection:lower() if aConnection:find( 'close', 1, true ) then aResponse.header[ 'connection' ] = 'close' end if aConnection:find( 'keep-alive', 1, true ) then aResponse.header[ 'connection' ] = 'keep-alive' end elseif aRequest.line.version == aResponse.status.version then aResponse.header[ 'connection' ] = 'keep-alive' end end function CookieFilter( aRequest, aResponse ) aResponse.header[ 'set-cookie' ] = tostring( aResponse.cookie ) end function GZIPFilter( aRequest, aResponse ) local aCode = aResponse.status.code local anEncoding = ( aRequest.header[ 'accept-encoding' ] or '' ):lower() local aContent = tostring( aResponse.content or '' ) if aCode >= 200 and aCode <= 299 and anEncoding:find( 'gzip', 1, true ) and aContent:len() > 0 then -- Requires Tiago Dionizio's lzlib library -- http://mega.ist.utl.pt/~tngd/lua/lzlib-0.2.tar.gz local ok, zlib = pcall( require, 'zlib' ) if ok and zlib then local aCompressedContent = zlib.compress( aContent, 9, nil, 15 + 16 ) if aCompressedContent:len() < aContent:len() then local aVary = aResponse.header[ 'vary' ] or 'accept-encoding' if aVary ~= 'accept-encoding' then aVary = aVary .. ',' .. 'accept-encoding' end aResponse.header[ 'content-encoding' ] = 'gzip' aResponse.header[ 'vary' ] = aVary aResponse.content = aCompressedContent aResponse.header[ 'content-length' ] = aCompressedContent:len() end end end end function ETagFilter( aRequest, aResponse ) local aCode = aResponse.status.code local aEtag = aResponse.header[ 'etag' ] local aContent = tostring( aResponse.content or '' ) if aCode >= 200 and aCode <= 299 and not aEtag and aContent:len() > 0 then -- Requires Luiz Henrique de Figueiredo's lmd5 library -- http://www.tecgraf.puc-rio.br/~lhf/ftp/lua/5.0/lmd5.tar.gz local ok, _ = pcall( require, 'md5' ) if ok and md5 then aResponse.header[ 'etag' ] = md5.digest( aContent ) end end end function RangeFilter( aRequest, aResponse ) local aCode = aResponse.status.code local aRange = ( aRequest.header[ 'range' ] or '' ):lower() local aContent = tostring( aResponse.content or '' ) if aCode >= 200 and aCode <= 299 and aRange and aContent:len() > 0 then end end function ConditionalFilter( aRequest, aResponse ) local aCode = aResponse.status.code local modifiedSince = aRequest.header[ 'if-modified-since' ] or 0 local lastModified = aResponse.header[ 'last-modified' ] or 1 local noneMatch = aRequest.header[ 'if-none-match' ] or 0 local etag = aResponse.header[ 'etag' ] or 1 if ( aCode >= 200 and aCode <= 299 ) and ( modifiedSince == lastModified or noneMatch == etag ) then aResponse.status.code = 304 aResponse.status.description = 'Not Modified' aResponse.content = nil end end function MD5Filter( aRequest, aResponse ) local aContent = tostring( aResponse.content or '' ) if aContent:len() > 0 then -- Requires Luiz Henrique de Figueiredo's lmd5 library -- http://www.tecgraf.puc-rio.br/~lhf/ftp/lua/5.0/lmd5.tar.gz local ok, _ = pcall( require, 'md5' ) if ok and md5 then aResponse.header[ 'content-md5' ] = md5.digest( aContent ) end end end function HeadFilter( aRequest, aResponse ) local aMethod = ( aRequest.line.method or '' ):lower() if aMethod == 'head' then aResponse.content = nil end end function LogFilter( aRequest, aResponse ) end self.filter = { AuthorizationFilter, ConnectionFilter, CookieFilter, GZIPFilter, ETagFilter, RangeFilter, ConditionalFilter, MD5Filter, HeadFilter, LogFilter } function meta:__call() local aStatus = { version = 'HTTP/1.1', code = 200, description = 'OK' } local aHeader = { connection = 'close', date = os.date( '!%a, %d %b %Y %H:%M:%S GMT', os.time() ) } local aResponse = { status = aStatus, header = aHeader } setmetatable( aResponse, self ) self.__index = self return aResponse end function self:__tostring() local aBuffer = {} local aStatus = self.status or {} local aVersion = tostring( aStatus.version or 'HTTP/1.1' ) local aCode = tostring( aStatus.code or 200 ) local aDescription = tostring( aStatus.description or 'OK' ) local aHeader = self.header or {} local aContent = self.content or '' aBuffer[ #aBuffer + 1 ] = ( '%s %s %s '):format( aVersion, aCode, aDescription ) for aKey, aValue in pairs( aHeader ) do aBuffer[ #aBuffer + 1 ] = ( '%s: %s' ):format( tostring( aKey ), tostring( aValue ) ) end aBuffer[ #aBuffer + 1 ] = '' aBuffer[ #aBuffer + 1 ] = tostring( aContent ) return table.concat( aBuffer, '\r\n' ) end -------------------------------------------------------------------------------- -- HTTP.Handler -------------------------------------------------------------------------------- module( 'HTTP.Handler', package.seeall ) _DESCRIPTION = 'HTTP.handler' _VERSION = '1.0' function delete() HTTP.response.status.code = 403 HTTP.response.status.description = 'Forbidden' HTTP.response.header[ 'content-type' ] = 'text/plain' return '403 Forbidden' end function get() HTTP.response.status.code = 404 HTTP.response.status.description = 'Not Found' HTTP.response.header[ 'content-type' ] = 'text/plain' return '404 Not Found' end function head() return get() end function options() HTTP.response.status.code = 204 HTTP.response.status.description = 'No Content' HTTP.response.header[ 'allow' ] = 'DELETE, GET, HEAD, OPTIONS, POST, PUT, TRACE' return '' end function post() HTTP.response.status.code = 403 HTTP.response.status.description = 'Forbidden' HTTP.response.header[ 'content-type' ] = 'text/plain' return '403 Forbidden' end function put() HTTP.response.status.code = 403 HTTP.response.status.description = 'Forbidden' HTTP.response.header[ 'content-type' ] = 'text/plain' return '403 Forbidden' end function trace() HTTP.response.header[ 'content-type' ] = 'message/http' return HTTP.request end function notImplemented() HTTP.response.status.code = 501 HTTP.response.status.description = 'Not Implemented' HTTP.response.header[ 'content-type' ] = 'text/plain' return '501 Not Implemented' end -------------------------------------------------------------------------------- -- HTTP -------------------------------------------------------------------------------- module( 'HTTP', package.seeall ) _DESCRIPTION = 'HTTP' _VERSION = '1.0' local self = _M local meta = getmetatable( self ) self.handler = {} self.request = {} self.response = {} local function Filter( someFilters, aRequest, aResponse ) for anIndex, aFilter in ipairs( someFilters or {} ) do if aFilter( aRequest, aResponse ) then break end end end local function Pattern( aMethod, anHost, aPath ) local aBuffer = {} aBuffer[ #aBuffer + 1 ] = tostring( aMethod or '.*' ):lower() aBuffer[ #aBuffer + 1 ] = tostring( anHost or '.*' ):lower() aBuffer[ #aBuffer + 1 ] = tostring( aPath or '.*' ) return table.concat( aBuffer, '|' ) end local function Method( aMethod, aHandler ) if type( aHandler ) == 'string' then aHandler = require( aHandler ) end if type( aHandler ) == 'table' then aHandler = aHandler[ ( aMethod or '' ):lower() ] or aHandler.__call end if type( aHandler ) == 'function' then return aHandler end return HTTP.Handler.notImplemented end local function Handler( someHandlers, aRequest ) local aMethod = aRequest.line.method local anHost = aRequest.url.host local aPath = aRequest.url.path local aValue = Pattern( aMethod, anHost, aPath ) for anIndex, aList in ipairs( someHandlers ) do local aHandler = aList[ 1 ] local aPattern = aList[ 2 ] if aValue:find( aPattern ) then local someArguments = { aValue:match( aPattern ) } local aMethodHandler = Method( aMethod, aHandler ) return aMethodHandler, someArguments end end return Method( aMethod, HTTP.Handler ), {} end local function Dispatch( someHandlers, aRequest, aResponse ) Filter( aRequest.filter, aRequest, aResponse ) local aHandler, someArguments = Handler( someHandlers, aRequest ) local aContent, aLocation = aHandler( unpack( someArguments ) ) if aContent then aResponse.header[ 'content-type' ] = aResponse.header[ 'content-type' ] or 'text/html' aResponse.content = aContent end if aLocation then aResponse.status.code = 302 aResponse.status.description = 'Found' aResponse.header[ 'location' ] = aLocation end if not aContent and not aLocation then aResponse.status.code = 404 aResponse.status.description = 'Not Found' aResponse.header[ 'content-type' ] = 'text/plain' aResponse.content = '404 Not Found' end aContent = aResponse.content if aContent then aResponse.header[ 'content-length' ] = aResponse.header[ 'content-length' ] or tostring( aContent or '' ):len() end Filter( aResponse.filter, aRequest, aResponse ) return not ( aResponse.header[ 'connection' ] or '' ):find( 'close', 1, true ) end function meta:__call( aReader, aWriter ) local aReader = aReader or io.stdin local aWriter = aWriter or io.stdout while true do local aCall = function() aWriter:flush() self.request = HTTP.Request( aReader ) self.response = HTTP.Response() return Dispatch( self.handler, self.request, self.response ) end local aStatus, aResult = xpcall( aCall, debug.traceback ) if not aStatus then self.response = HTTP.Response() self.response.status.code = 500 self.response.status.description = 'Internal Server Error' self.response.header[ 'connection' ] = 'close' self.response.header[ 'content-type' ] = 'text/plain' self.response.header[ 'content-length' ] = tostring( aResult ):len() self.response.content = aResult aWriter:write( tostring( self.response ) ) break elseif not aResult then aWriter:write( tostring( self.response ) ) break end aWriter:write( tostring( self.response ) ) end return self end function meta:__newindex( aKey, aValue ) local someHandlers = self.handler if type( aValue ) == 'table' then if #aValue == 1 then aValue = { nil, nil, aValue[ 1 ] } elseif #aValue == 2 then aValue = { aValue[ 1 ], nil, aValue[ 2 ] } end else aValue = { nil, nil, aValue } end someHandlers[ #someHandlers + 1 ] = { aKey, Pattern( unpack( aValue ) ) } end function meta:__tostring() return _DESCRIPTION end