💾 Archived View for gemini.conman.org › extensions › GLV-1 › handlers › gemmention.lua captured on 2023-07-10 at 15:15:18.

View Raw

More Information

⬅️ Previous capture (2023-01-29)

-=-=-=-=-=-=-

-- ************************************************************************
--
--    Gemini mentions, a proof-of-concept
--    Copyright 2022 by Sean Conner.  All Rights Reserved.
--
--    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: sean@conman.org
--
-- ************************************************************************
-- luacheck: globals init handler
-- luacheck: ignore 611

local process = require "org.conman.process"
local syslog  = require "org.conman.syslog"
local errno   = require "org.conman.errno"
local nfl     = require "org.conman.nfl"
local fsys    = require "org.conman.fsys"
local tls     = require "org.conman.nfl.tls"
local url     = require "org.conman.parsers.url"
local mime    = require "org.conman.parsers.mimetype"
local blog    = require "org.conman.app.mod_blog"
local uurl    = require "GLV-1.url-util"
local lpeg    = require "lpeg"
local string  = require "string"
local io      = require "io"
local pairs   = pairs

_ENV = {}

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

function init(conf)
  if not conf.pattern then
    return false,"Missing pattern rule"
  end
  
  if not conf.file then
    return false,"Missing required Gemini text file"
  end
  
  return true
end

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

local parse_list_expr do
  local function doset(dest,name,value)
    if not dest[name] then
      dest[name] = value
    end
    return dest
  end
  
  local xdigit = lpeg.locale().xdigit
  local char   = lpeg.P"%" * lpeg.C(xdigit * xdigit)
               / function(c)
                   return string.char(tonumber(c,16))
                 end
               + lpeg.P(1)
  local name   = lpeg.Cs((char - (lpeg.P"=" + lpeg.P"&"))^1)
  local value  = lpeg.Cs((char - lpeg.P"&")^1) + lpeg.Cc""
  local pair   = name * (lpeg.P"=" * value)^-1 * lpeg.P"&"^-1
  
  parse_list_expr = lpeg.Cf(lpeg.Ct"" * (lpeg.Cg(pair))^0,doset)
end

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

local statparse do
  local Cc = lpeg.Cc
  local C  = lpeg.C
  local P  = lpeg.P
  local R  = lpeg.R
  local S  = lpeg.S
  
  local status   = P"1" * R"09" * Cc'input'    * Cc'required'  * Cc(true)
                 + P"2" * R"09" * Cc'okay'     * Cc'content'   * Cc(true)
                 + P"3" * R"09" * Cc'redirect' * Cc'temporary' * Cc(true)
                 + P"4" * R"09" * Cc'error'    * Cc'temporary' * Cc(true)
                 + P"5" * R"09" * Cc'error'    * Cc'permanent' * Cc(true)
                 + P"6" * R"09" * Cc'auth'     * Cc'required'  * Cc(true)
  local infotype = S" \t"^1 * C(R" \255"^0)
                 + Cc"type/text; charset=utf-8"
  statparse      = status * infotype
end

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

local function checktarget(ios,target,doctitle)
  local line = ios:read("*l")
  if not line then
    return false
  end
  
  if not doctitle and line:match "^#%s*[^#]" then
    doctitle = line:match("^#%s*(.*)")
    return checktarget(ios,target,doctitle)
  end
  
  if not line:match"^=>" then return checktarget(ios,target,doctitle) end
  local u,title   = line:match("^=>%s*(%S+)%s+(.*)")
  
  if not u then
    u = line:match9("^=>%s*(%S+)")
    title = ""
  end
  
  local loc = url:match(u)
  if not loc                     then return checktarget(ios,target,doctitle) end
  if target.scheme ~= loc.scheme then return checktarget(ios,target,doctitle) end
  if target.host   ~= loc.host   then return checktarget(ios,target,doctitle) end
  if target.port   ~= loc.port   then return checktarget(ios,target,doctitle) end
  if target.path   ~= loc.path   then return checktarget(ios,target,doctitle) end
  return doctitle or title
end

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

local function gemini_fetch(u,location,target,rcount)
  local ios = tls.connect(location.host,location.port,nil,function(conf)
    conf:insecure_no_verify_name()
    conf:insecure_no_verify_time()
    conf:insecure_no_verify_cert()
    return conf:protocols "all"
  end)
  
  if not ios then
    return
  end
  
  if not ios:write(u,"\r\n") then
    ios:close()
    return
  end
  
  local statline = ios:read("*l")
  if not statline then
    ios:close()
    return
  end
  
  local system,_,_,info = statparse:match(statline)
  if not system then
    ios:close()
    return
  end
  
  if system == 'auth' then
    ios:close()
    return
    
  elseif system == 'redirect' then
    ios:close()
    if rcount == 5 then
      return
    end
    local where = url:match(info)
    local new   = uurl.merge(location,where)
    local newloc = uurl.toa(new)
    
    return gemini_fetch(ios,newloc,target,rcount + 1)
    
  elseif system == 'okay' then
    info = mime:match(info)
    if not info or info.type ~= 'text/gemini' then
      ios:close()
      return
    end
    local title = checktarget(ios,target)
    ios:close()
    return title
    
  else
    ios:close()
    return
  end
end

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

function handler(conf,_,loc,_,ios)
  local function append(file,location,title)
    for line in file:lines() do
      local u = line:match("^(%S+)")
      if u == location then
        return
      end
    end
    file:write(location,"\t",title,"\n")
  end
  
  if not loc.query or loc.query == "" or loc.query == 'gemini-mention' then
    local f,err = io.open(conf.file,"r")
    if not f then
      syslog('error',"%s: %s",conf.file,err)
      ios:write("40\r\n")
      return 40
    end
    
    ios:write("20 text/gemini\r\n")
    repeat
      local data = f:read(1024)
      if data then ios:write(data) end
    until not data
    
    f:close()
    return 20
  end
  
  local args = parse_list_expr:match(loc.query)
  
  if not args.source or not args.target then
    ios:write("59 Bad request\r\n")
    return 59
  end
  
  local su = url:match(args.source)
  local tu = url:match(args.target)
  
  if not su or not tu then
    ios:write("59 Bad request\r\n")
    return 59
  end
  
  if tu.host ~= loc.host or tu.port ~= loc.port then
    ios:write("59 Bad request\r\n")
    return 59
  end
  
  local year,month,day,part = tu.path:match(conf.pattern)
  if not year then
    ios:write("59 Bad request\r\n")
    return 59
  end
  
  local title = gemini_fetch(args.source,su,tu,1)
  if not title then
    ios:write("59 Bad request\r\n")
    return 59
  end
  
  local file = blog.filename {
        start    = { year = year , month = month , day = day } ,
        filename = string.format("%d.webmention",part)
  }
  local f,err = io.open(file,"a+")
  if not f then
    syslog('error',"%s: %s",file,err)
    ios:write("59 bad request\r\n")
    return 59
  end
  
  -- -----------------------------------------------------------------
  -- In order to avoid blocking the entire process, we need to call
  -- fsys._lock() with the non-blocking option, and if we would lock, just
  -- yield our coroutine and try again.  Yes, this may burn some CPU cycles,
  -- but it shouldn't be that bad.  I can always think on this if it does
  -- become an issue.
  -- -----------------------------------------------------------------
  
  local function lock()
    local okay,err1 = fsys._lock(f,'write',true)
    if okay then return okay end
    if err1 == errno.EACCESS or err1 == errno.EAGAIN then
      nfl.schedule(coroutine.running())
      coroutine.yield()
      return lock()
    else
      return okay,err1
    end
  end
  
  local okay,err2 = lock()
  if not okay then
    syslog('error',"%s: %s",file,errno[err2])
    ios:write("40 Temporary failure\r\n")
    return 40
  end
  
  append(f,uurl.toa(su),title)
  fsys._lock(f,'release',true)
  f:close()
  
  ios:write("20 text/plain\r\nIt's been accepted.  Thank you.\n")

  if conf.update then
    local child,err1 = process.fork()
    if not child then
      syslog('error',"fork() = %s",errno[err1])
    elseif child == 0 then
      process.exec(conf.update.cmd,conf.update.args,conf.update.env)
      process.exit(1)
    else
      local info,err3 = process.wait(child)
      if not info then
        syslog('error',"wait() = %s",errno[err3])
      else
        if info.status ~= 'normal' or info.rc ~= 0 then
          syslog('error',"%s: status=%q description=%q rc=%d",info.status,info.description,info.rc)
        end
      end
    end
  end
  
  if conf.email then
    local mail = io.popen("/usr/sbin/sendmail " .. conf.email,"w")
    if mail then
      mail:write(
        string.format("From: <%s>\n",conf.email),
        string.format("To: <%s>\n",conf.email),
        "Subject: Gemini mention, sir!\n",
        "\n",
        string.format("From: %s\n",ios.__remote.addr),
        "Expected Values:\n",
        string.format("\tsource: %s\n",args.source),
        string.format("\ttarget: %s\n",args.target),
        "\n",
        "All values:\n"
      )
      
      for name,value in pairs(args) do
        mail:write(string.format("%s: %s\n",name,value))
      end
      
      mail:close()
    end
  end
  
  return 20
end

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

return _ENV