-- ***********************************************************************
--
-- Copyright 2016 by Sean Conner.
--
-- This program is free software: you can redistribute it and/or modify it
-- under the terms of the GNU General Public License as published by the
-- Free Software Foundation, either version 3 of the License, or (at your
-- option) any later version.
--
-- This program is distributed in the hope that it will be useful, but
-- WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General
-- Public License for more details.
--
-- You should have received a copy of the GNU General Public License along
-- with this program.  If not, see <http://www.gnu.org/licenses/>.
--
-- Comments, questions and criticisms can be sent to: [email protected]
--
-- =======================================================================
--
-- Code to handle gopher requests.
--
-- ***********************************************************************
-- luacheck: globals INFO FILE DIR ERROR HTML ACL
-- luacheck: globals config blog path_okay init main
-- luacheck: globals display_page display_request display_text_file
-- luacheck: globals display_fs_object display_index_file
-- luacheck: ignore 611

local process = require "org.conman.process"
local syslog  = require "org.conman.syslog"
local fsys    = require "org.conman.fsys"
local char    = require "org.conman.parsers.ascii.char"
             + require "org.conman.parsers.utf8.char"
local lpeg    = require "lpeg"
local bible   = require "bible"
local movie   = require "movie"
local get     = require "get"
local string  = require "string"
local io      = require "io"
local table   = require "table"

local config   = config
local blog     = blog
local tostring = tostring
local ipairs   = ipairs
local pairs    = pairs
local type     = type
local pcall    = pcall
local _VERSION = _VERSION

if _VERSION == "Lua 5.1" then
 module("handler")
else
 _ENV = {}
end

-- ***********************************************************************
-- Access Control List---a list of patterns to be applied to files to
-- prevent them from being accessed.
-- ***********************************************************************

ACL =
{
 { "^%..*"     , false } ,
 { ".*%~$"     , false } ,
 { "/%.%./"    , false } ,
 { "/%."       , false } ,
 { "^build$"   , false } ,
 { "^main$"    , false } ,
 { "%.so$"     , false } ,
 { "%.o$"      , false } ,
 { "%.a$"      , false } ,
 { ".*"        , true  }
}

-- ***********************************************************************
-- Usage:       okay = handler.path_okay(acl,path)
-- Desc:        Check a filename against an ACL list
-- Input:       acl (table, see above)
--              path (string) filepath
-- Return:      okay (boolean)
-- ***********************************************************************

function path_okay(acl,path)
 for i = 1 , #acl do
   if path:match(acl[i][1]) then
     return acl[i][2]
   end
 end
 return false
end

-- ***********************************************************************
-- Usage:       link = handler.INFO(info)
-- Desc:        Return a gopher formatted INFO link (text)
-- Input:       info (table)
--                      * [1] = INFO
--                      * [2] = string, function or table
--                              | if function, it should return text
--                              | if table, an array of strings
--                              | if string, use that
-- Return:      link (string) a formatted INFO link
-- ***********************************************************************

function INFO(info)
 if type(info[2]) == 'function' then
   return INFO { INFO , info[2]() }

 elseif type(info[2]) == 'table' then
   local ret = ""
   for i = 1 , #info[2] do
     ret = ret .. INFO { INFO , info[2][i] }
   end
   return ret

 else
   return string.format("i%s\t\texample.org\t70\r\n",tostring(info[2]))
 end
end

-- ***********************************************************************
-- Usage:       link = handler.FILE(info)
-- Desc:        Return a gopher formatted FILE link
-- Input:       info (table)
--                      * [1] = FILE
--                      * [2] = label (string or function)
--                      * [3] = selector (string or function)
--                      * [4] = remotehost (string/optional)
--                      * [5] = remoteport (string/optional)
-- Return:      link (string) a formatted FILE link
-- ***********************************************************************

function FILE(info)
 local host = info[4] or config.interface.hostname
 local port = info[5] or config.interface.port

 if type(info[2]) == 'function' then
   info[2] = info[2]()
 end

 if type(info[3]) == 'function' then
   info[3] = info[3]()
 end

 return string.format("0%s\t%s\t%s\t%s\r\n",info[2],info[3],host,port)
end

-- ***********************************************************************
-- Usage:       link = handler.DIR(info)
-- Desc:        Return a gopher formatted DIR link
-- Input:       info (table)
--                      * [1] = DIR
--                      * [2] = label (string)
--                      * [3] = selector (string)
--                      * [4] = remotehost (string/optional)
--                      * [5] = remoteport (string/optional)
-- Return:      link (string) a formatted DIR link
-- ***********************************************************************

function DIR(info)
 local host = info[4] or config.interface.hostname
 local port = info[5] or config.interface.port
 return string.format("1%s\t%s\t%s\t%s\r\n",info[2],info[3],host,port)
end

-- ***********************************************************************
-- Usage:       link = handler.ERROR(info)
-- Desc:        Return a gopher formatted ERROR
-- Input:       info (table)
--                      * [1] = ERROR
--                      * [2] = label (string)
--                      * [3] = selector (string)
--                      * [4] = remotehost (string/optional)
--                      * [5] = remoreport (string/optional)
-- Return:      link (string) a formatted ERROR
-- ***********************************************************************

function ERROR(info)
 local host = info[4] or config.interface.hostname
 local port = info[5] or config.interface.port
 return string.format("3%s\t%s\t%s\t%s\r\n",info[2],info[3],host,port)
end

-- ***********************************************************************
-- Usage:       link = handler.HTML(info)
-- Desc:        Return a gopher formatted HTML link
-- Input:       info (table)
--                      * [1] = HTML
--                      * [2] = label (string)
--                      * [3] = selector (string)
--                      * [4] = remotehost (string/optional)
--                      * [5] = remoteport (string/optional)
-- Return:      link (string) a formatted HTML link
-- ***********************************************************************

function HTML(info)
 local host = info[4] or config.interface.hostname
 local port = info[5] or config.interface.port
 return string.format("h%s\t%s\t%s\t%s\r\n",info[2],info[3],host,port)
end

-- ***********************************************************************
-- This array of formats is passed to the blog module.
-- ***********************************************************************

local FORMATS =
{
 ['INFO']  = INFO,
 ['FILE']  = FILE,
 ['DIR']   = DIR,
 ['ERROR'] = ERROR,
 ['HTML']  = HTML,

 ['info']  = INFO,
 ['file']  = FILE,
 ['dir']   = DIR,
 ['error'] = ERROR,
 ['html']  = HTML
}

-- ***********************************************************************
-- The main page, an array of gopher links.  This should show more about how
-- the functions above are called.
-- ***********************************************************************

local top_page =
{
 { INFO , "Welcome to Conman Laboratories" },

 { INFO , "" },
 { INFO , "NOTE: RFC-1436 says this about selectors:" },
 { INFO , "" },
 { INFO , "    ... an OPAQUE selector string ...  The selector string should MEAN" },
 { INFO , "    NOTHING to the client software; it should never be modified by the" },
 { INFO , "    client." },
 { INFO , "" },
 { INFO , "(emphasis added)" },
 { INFO , "" },
 { INFO , "The selectors on this server *ARE OPAQUE* and *MUST* be sent *AS IS* to" },
 { INFO , "the server. Please note that the selectors here rarely start with a '/'" },
 { INFO , "character. Particularely, phlog entries start with a selector of" },
 { INFO , [["Phlog:"---note the lack of '/' and the ending ':'.]] },
 { INFO , "" },
 { INFO , "Thank you." },
 { INFO , "  -- The Management" },

 { INFO , "" },
 { FILE , "About the server"                  , "About:Server" } ,
 { FILE , "About the author"                  , "About:Me"     } ,
 { DIR  , "Gopher Server Source Code"         , "Gopher:Src:"  } ,
 { DIR  , "Boston source code"                , "Boston:Src:"  } ,
 { DIR  , "CGILib source code"                , "CGI:Src:"     } ,
 { DIR  , "The Boston Diaries Phlog Feed"     , "phlog.gopher" } ,
 { DIR  , "The Boston Diaries Phlog Archives" , "Phlog:"       } ,
 { FILE , "Latest Phlog Post"                 , blog.last_link } ,
 { DIR  , "The Electric King James Bible"     , "Bible:"       } ,
 { DIR  , "The Quick and Dirty B-Movie Plot Generator" , "Movie:" } ,

 { INFO , "" } ,
 { DIR  , "Other Gopher Servers"       , "/world" , "gopher.floodgap.com" , 70 } ,
 { DIR  , "Phlog aggregator"           , "/bongusta/" , "i-logout.cz"  , 70 } ,
 { DIR  , "Another Phlog aggregator"   , "/moku-pona"    , "gopher.black" , 70 } ,
 { DIR  , "Lobste.rs mirror"           , "/users/julienxx/Lobste.rs"      , "sdf.org" , 70 } ,
 { DIR  , "jstg gopher"                , "/users/jstg/"  , "sdf.org"      , 70 } ,
 { DIR  , "phlogs"                     , "/phlogs/"      , "sdf.org"      , 70 } ,
 { DIR  , "some stuff"                 , ""              , "sdf.org"      , 70 } ,
 { INFO , "" } ,
 { INFO , "Wisdom of the day:" } ,
 { INFO , function()
            local res = {}
            local q   = io.popen(config.quotes,"r")
            if q ~= nil then
              for line in q:lines() do
                table.insert(res,line)
              end
              q:close()
            else
              table.insert(res,"A witty saying goes here")
              table.insert(res,"               -- Anon")
            end
            return res
          end },
 { FILE , "robots.txt-because we can" , "/robots.txt" } ,
}

-- ***********************************************************************
-- Usage:       handler.display_page(page)
-- Desc:        Construct a gopher page
-- Input:       page (array of links) - see example of top_page
-- ***********************************************************************

function display_page(page)
 local size = 0

 for _,line in ipairs(page) do
   local t = line[1](line)
   size = size + #t
   io.stdout:write(line[1](line))
 end

 io.stdout:write(".\r\n")
 return size + 3
end

-- ***********************************************************************
-- Usage:       handler.display_request(selector,request)
-- Desc:        Take a request and handle it
-- Input:       selector (table) (see below for an example)
--              request (string) gopher request
-- ***********************************************************************

function display_request(selector,request)
 for regex,code in pairs(selector) do
   local pattern = request:match(regex)
   if pattern then
     return code(pattern)
   end
 end

 syslog('error',"%q not found",request)
 local err = ERROR { ERROR , "Not found" , request }
 io.stdout:write(err,".\r\n")
 return #err + 3
end

-- ***********************************************************************
-- Usage:       handler.display_index_file(fname)
-- Desc:        Display a gopher index file via gopher
-- Input:       fname (string) filename
-- ***********************************************************************

local parsetype = lpeg.P"\t"
               * lpeg.C(char^0) * lpeg.P"\t" -- type
               * lpeg.C(char^0) * lpeg.P"\t" -- display
               * lpeg.C(char^0)              -- selector

function display_index_file(fname)
 local page = {}
 for line in io.lines(fname) do
   if line == "" or line:match("^[^%c]") then
     table.insert(page, { INFO , line })
   else
     local ftype,display,selector = parsetype:match(line)
     table.insert(page , { FORMATS[ftype] , display , selector })
   end
 end

 return display_page(page)
end

-- ***********************************************************************
-- Usage:       handler.display_text_file(fname)
-- Desc:        Display a text file via gopher
-- Input:       fname (string) filename
-- ***********************************************************************

function display_text_file(fname)
 local size = 0

 for line in io.lines(fname) do
   if line:match("^%.") then
     io.stdout:write(".")
     size = size + 1
   end
   io.stdout:write(line,"\r\n")
   size = size + #line + 2
 end

 return size
end

-- ***********************************************************************
-- Usage:       handler.display_fs_object(acl,selector,req,path)
-- Desc:        Display a directory listing of files (path, then files)
-- Input:       acl (table) ACL list
--              selector (string) base gopher selector
--              req (string) directory from gopher request
--              path (string) absolute directory
-- ***********************************************************************

function display_fs_object(acl,selector,req,path)
 if not path_okay(acl,req) then
   local err = ERROR { ERROR , "Not found" , req }
   io.stdout:write(err, ".\r\n")
   return #err + 3
 end

 local info = fsys.stat(path)
 if info == nil then
   local err = ERROR { ERROR , "Not found" , req }
   io.stdout:write(err, ".\r\n")
   return #err + 3
 end

 if info.mode.type == 'file' then
   return display_text_file(path)
 elseif info.mode.type ~= 'dir' then
   local err = ERROR { ERROR , "Not found" , req }
   io.stdout:write(err,".\r\n")
   return #err + 3
 end

 local directories = {}
 local files       = {}

 for file in fsys.dir(path) do
   if path_okay(acl,file) then
     info = fsys.stat(path .. "/" .. file)

     if info.mode.type == 'file' then
       table.insert(files,file)
     elseif info.mode.type == 'dir' then
       table.insert(directories,file)
     end
   end
 end

 table.sort(directories)
 table.sort(files)

 if req ~= "" then
   req = req .. "/"
 end

 local size = 0

 for i = 1 , #directories do
   local dir = DIR { DIR , directories[i] , selector .. req .. directories[i] }
   io.stdout:write(dir)
   size = size + #dir
 end

 for i = 1 , #files do
   local file = FILE { FILE , files[i] , selector .. req .. files[i] }
   io.stdout:write(file)
   size = size + #file
 end

 io.stdout:write(".\r\n")
 return size + 3
end

-- ***********************************************************************
-- This table maps request selectors to functions to handle them.  The keys
-- are patterns the request is matched against---first match wins, so order
-- accordingly.
-- ***********************************************************************

local selectors =
{
 ['^/robots%.txt$'] = function()
   io.stdout:write("User-agent: *\r\nDisallow:\r\n")
   return 26
 end,

 ['^robots%.txt$'] = function()
   io.stdout:write("User-agent: *\r\nDisallow:\r\n")
   return 26
 end,

 ['^/phlog.gopher$'] = function()
   return display_text_file(config.files .. "/phlog.gopher")
 end,

 ['^phlog.gopher$'] = function()
   return display_text_file(config.files .. "/phlog.gopher")
 end,

 ['^caps%.txt$'] = function()
   return display_text_file(config.files .. "/caps.txt")
 end,

 ['^/caps%.txt$'] = function()
   return display_text_file(config.files .. "/caps.txt")
 end,

 ['^About%:Server$'] = function()
   return display_text_file(config.files .. "/about-server.txt")
 end,

 ['^About%:Me$'] = function()
   return display_text_file(config.files .. "/about-me.txt")
 end,

 ['^Boston%:Src%:(.*)'] = function(req)
   return display_fs_object(
     ACL,
     "Boston:Src:",
     req,
     "/home/spc/source/boston/" .. req
   )
 end,

 ['^CGI%:Src%:(.*)'] = function(req)
   return display_fs_object(
     ACL,
     "CGI:Src:",
     req,
     "/home/spc/source/cgi/" .. req
   )
 end,

 ['^Phlog%:?(.*)'] = function(req)
   local size
   local data,text = blog.display(FORMATS,req)
   if not data then
     return 0
   elseif type(data) == 'table' then
     return display_page(blog.display(FORMATS,req))
   elseif type(data) == 'string' then
     io.stdout:write(data)
     return #data
   elseif io.type(data) == 'file' then
     if text then
       size = 0
       for line in data:lines() do
         io.stdout:write(line,"\r\n")
         size = size + #line + 2
       end
     else
       local pic = data:read("*a")
       size      = #pic
       io.stdout:write(pic)
     end
     data:close()
     return size
   else
     local data = tostring(data) -- luacheck: ignore
     io.stdout:write(data)
     return #data
   end
 end,

 ['^Gopher%:Src%:(.*)'] = function(req)
   return display_fs_object(ACL,"Gopher:Src:",req,"/home/spc/source/gopher-blog/" .. req)
 end,

 ['^Bible%:(.*)'] = function(req)
   if req == "" then
     return display_index_file(config.files .. "/electric-king-james.index")
   else
     return bible.handle(req)
   end
 end,

 ['^Movie%:(.*)'] = function(req)
   return movie.handler(req)
 end,

 ['^GET%s+.*']      = get.handler,
 ['^HEAD%s+.*']     = get.handler,
 ['^POST%s+.*']     = get.handler,
 ['^PUT%s+.*']      = get.handler,
 ['^DELETE%s+.*']   = get.handler,
 ['^CONNECT%s+.*']  = get.handler,
 ['^OPTIONS%s+.*']  = get.handler,
 ['^TRACE%s+.*']    = get.handler,
 ['^BREW%s+.*']     = get.handler, -- RFC-2324, in case people get cute
 ['^PROPFIND%s+.*'] = get.handler,
 ['^WHEN%s+.*']     = get.handler,
}

-- ***********************************************************************
-- Usage:       handler.main(remote)
-- Desc:        Main gopher request handler
-- Input:       remote (userdata/address) remote address
-- Note:        This does not return, but exits the process
-- ***********************************************************************

function main(remote)
 local request = io.stdin:read("*l")
 if request then
   request = request:gsub("\013","")
 else
   io.stdout:write(ERROR { ERROR , "Bad request" , "" },"\r\n")
   process.exit(0)
 end
 local okay
 local size

 if request == "" or request == "/" then
   okay,size = pcall(display_page,top_page)
 else
   okay,size = pcall(display_request,selectors,request)
 end

 if not okay then
   syslog('error',"host=%s request=%q err=%q",remote.addr,request,size)
 else
   syslog('info',"host=%s request=%q size=%d",remote.addr,request,size)
 end
 process.exit(0)
end

-- ***********************************************************************
-- Usage:       handler.init()
-- Desc:        Initialize the handler module.
-- ***********************************************************************

function init()
 if config.interface.port == 70 then
   config.url = string.format(
       "gopher://%s/",
       config.interface.hostname
   )
 else
   config.url = string.format(
       "gopher://%s:%d/",
       config.interface.hostname,
       config.interface.port
   )
 end
end

-- ***********************************************************************

if _VERSION >= "Lua 5.2" then
 return _ENV
end