#!/usr/bin/env lua
-- ***********************************************************************
--
-- Copyright 2019 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]
--
-- ***********************************************************************
-- luacheck: ignore 611

local syslog  = require "org.conman.syslog"
local signal  = require "org.conman.signal"
local nfl     = require "org.conman.nfl"
local tcp     = require "org.conman.nfl.tcp"
local exit    = require "org.conman.const.exit"
local lpeg    = require "lpeg"
local setugid = require "port70.setugid"

math.randomseed(require("org.conman.math").seed())
require("org.conman.fsys.magic"):flags('mime')

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

if #arg == 0 then
 io.stderr:write(string.format("usage: %s configfile\n",arg[0]))
 os.exit(exit.USAGE,true)
end

local CONF = {} do
 local conf,err = loadfile(arg[1],"t",CONF)
 if not conf then
   io.stderr:write(string.format("%s: %s\n",arg[1],err))
   os.exit(exit.CONFIG,true)
 end

 conf()

 if not CONF.syslog then
   CONF.syslog = { ident = 'gopher' , facility = 'daemon' }
 else
   CONF.syslog.ident    = CONF.syslog.ident    or 'gopher'
   CONF.syslog.facility = CONF.syslog.facility or 'daemon'
 end

 syslog.open(CONF.syslog.ident,CONF.syslog.facility)

 if not CONF.network
 or not CONF.network.host then
   syslog('critical',"%s: missing or bad network configuration",arg[1])
   io.stderr:write(string.format("%s: missing or bad network configuration",arg[1]),"\n")
   os.exit(exit.CONFIG,true)
 end

 if not CONF.network.addr then
   CONF.network.addr = "::"
 end

 if not CONF.network.port then
   CONF.network.port = 70
 end

 if not CONF.redirect then
   CONF.redirect = { permanent = {} , gone = {} }
 else
   CONF.redirect.permanent = CONF.redirect.permanent or {}
   CONF.redirect.gone      = CONF.redirect.gone      or {}
 end

 package.loaded['port70.CONF'] = CONF

 if not CONF.handlers or #CONF.handlers == 0 then
   syslog('critical',"%s: at least one handler needs to be defined",arg[1])
   io.stderr:write(string.format("%s: at least one handler needs to be defined",arg[1]),"\n")
   os.exit(exit.CONFIG,true)
 end

 local function loadmodule(info)
   local function notfound()
     return false,"Selector not found"
   end

   if not info.selector then
     syslog('error',"%q: missing selector field",info.module or "")
     io.stderr:write(string.format("%q: missing selector field",info.module or ""),"\n")
     info.selector = ""
     info.code = { handler = notfound }
     return
   end

   if not info.module then
     syslog('error',"%q: missing module field",info.selector or "")
     io.stderr:write(string.format("%q: missing module field",info.selector or ""),"\n")
     info.code = { handler = notfound }
     return
   end

   local okay,mod = pcall(require,info.module)
   if not okay then
     syslog('error',"%q %s",info.selector,mod)
     io.stderr:write(string.format("%q %s",info.selector,mod),"\n")
     info.code = { handler = notfound }
     return
   end

   if type(mod) ~= 'table' then
     syslog('error',"%q module %s not supported",info.selector,info.module)
     io.stderr:write(string.format("%q module %s not supported",info.selector,info.module),"\n")
     info.code = { handler = notfound }
     return
   end

   if not mod.handler then
     syslog('error',"%q missing %s.handler()",info.selector,info.module)
     io.stderr:write(string.format("%q missing %s.handler()",info.selector,info.module),"\n")
     mod.handler = notfound
     return
   end

   if mod.init then
     okay,err = mod.init(info)
     if not okay then
       syslog('error',"%q %s=%s",info.selector,info.module,err)
       io.stderr:write(string.format("%q %s=%s",info.selector,info.module,err),"\n")
       mod.handler = notfound
       return
     end
   end

   info.code = mod
 end

 table.sort(CONF.handlers,function(a,b)
   return #a.selector == #b.selector and a.selector < b.selector
       or #a.selector > #b.selector
 end)

 for i,info in ipairs(CONF.handlers) do
   if i < #CONF.handlers and info.selector == CONF.handlers[i+1].selector then
     syslog('warning',"duplicate selector %q found",info.selector)
     io.stderr:write(string.format("duplicate selector %q found",info.selector),"\n")
   end
   loadmodule(info)
 end
end

local mklink = require "port70.mklink" -- XXX hack

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

local redirect_subst do
 local replace  = lpeg.C(lpeg.P"$" * lpeg.R"09") * lpeg.Carg(1)
                / function(c,t)
                    c = tonumber(c:sub(2,-1))
                    return t[c]
                  end
 local char     = replace + lpeg.P(1)
 redirect_subst = lpeg.Cs(char^1)
end

local function redirect(ios,selector)
 for _,rule in ipairs(CONF.redirect.permanent) do
   local match = table.pack(selector:match(rule[1]))
   if #match > 0 then
     ios:write(mklink {
               type     = 'error',
               display  = "Permanent redirect",
               selector = redirect_subst:match(rule[2],1,match)
               })
     return true
   end
 end

 for _,pattern in ipairs(CONF.redirect.gone) do
   if selector:match(pattern) then
     ios:write(mklink {
               type     = 'error',
               display  = "Gone",
               selector = selector
       })
     return true
   end
 end
end

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

local parserequest = lpeg.C(lpeg.R" ~"^0)
                  * (lpeg.P"\t" * lpeg.C(lpeg.R" ~"^1))^-1
                  * lpeg.P(-1)
                  + lpeg.Cc(nil)

local function main(ios)
 local request = ios:read("*l")
 if not request then
   syslog(
       'info',
       "remote=%s status=false request=%q bytes=%d",
       ios.__remote.addr,
       "",
       0
   )
   ios:close()
 end

 local selector,search = parserequest:match(request)
 local binary          = false
 local okay            = false
 local found           = false

 if selector then
   if redirect(ios,selector) then
     found = true -- but it's been moved, or it's gone
   else
     for _,info in ipairs(CONF.handlers) do
       if selector:sub(1,#info.selector) == info.selector then
         found     = true
         local req =
         {
           selector = info.selector,
           rest     = selector:sub(#info.selector + 1,-1),
           search   = search,
           remote   = ios.__remote,
         }

         okay,binary = info.code.handler(info,req,ios)
         break
       end
     end
   end
 else
   ios:write(mklink { type = 'error' , display = "Bad request" , selector = selector })
 end

 if not found then
   ios:write(mklink { type = 'error' , display = "Selector not found" , selector = selector })
 end

 if not binary then
   ios:write(".\r\n")
 end

 syslog(
       'info',
       "remote=%s status=%s request=%q bytes=%d",
       ios.__remote.addr,
       tostring(okay),
       request,
       ios.__wbytes
 )
 ios:close()
end

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

local okay,err = tcp.listen(CONF.network.addr,CONF.network.port,main)

if not okay then
 io.stderr:write(string.format("%s: %s\n",arg[1],err))
 syslog('error',"%s: %s",arg[1],err)
 os.exit(exit.OSERR,true)
end

if not setugid(CONF.user) then
 os.exit(exit.CONF,true)
end

signal.catch('int')
signal.catch('term')
syslog('info',"entering service")

nfl.server_eventloop(function() return signal.caught() end)

for _,info in ipairs(CONF.handlers) do
 if info.fini then
   local ok,status = pcall(info.code.fini,info)
   if not ok then
     syslog('error',"%s: %s",info.module,status)
   end
 end
end

os.exit(true,true)