#!/usr/bin/env lua
-- QWiKBoard: A QWK-powered message board system
-- Copyright (C) 2024 plugd
-- 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 .
local posix = require "posix"
local usage = [[Usage: qwikboard DIR COMMAND arguments
Here DIR is the path to the message board directory and COMMAND is one of:
newcfg
init
qwkout
repin
nncp_handler
Each command accepts different command-line arguments.]]
qb = {}
local function load_config ()
local config_file = qb.path .. "/config.lua"
io.write("Loading config from file '" .. config_file .. "'... ")
dofile(config_file)
print("done.")
end
--- Utility functions ---
local function write_object (fh, obj, lev)
lev = lev or 0
local ind = string.rep("\t", lev)
if type(obj)=="table" then
fh:write("{\n")
for k,v in pairs(obj) do
fh:write(ind .. "\t[" .. string.format("%q",k) .. "]=")
write_object(fh, v, lev+1)
fh:write(",\n")
end
fh:write(ind .. "}")
else
fh:write(string.format("%q",obj))
end
end
local function print_object(obj)
write_object(io.stdout, obj)
end
local function space_pad(s, n)
return string.sub(s,1,n) .. string.rep(" ", math.max(0,n-string.len(s)))
end
local fs = {}
function fs.exists(filename)
return io.open(filename, "r")
end
function fs.direxists(dirname)
return pcall(posix.dir, dirname)
end
function fs.dir(path)
return posix.dir(path)
end
function fs.mkdir(dirname)
posix.mkdir(dirname)
end
function fs.rmdir(dirname, force)
if force then
for i,fname in ipairs(posix.glob(dirname .. "/*")) do
posix.unlink(fname)
end
end
assert(posix.rmdir(dirname))
end
function fs.rmfile(filename)
posix.unlink(filename)
end
function fs.copy(filename, destfilename, skip_if_not_found)
if skip_if_not_found and not fs.exists(filename) then
return
end
src = assert(io.open(filename,"r"))
dest = assert(io.open(destfilename,"w"))
dest:write(src:read("*all"))
dest:close()
src:close()
end
function fs.touch(filename)
fh = assert(io.open(filename,"w"))
fh:close()
end
function fs.mktempdir()
return posix.mkdtemp("/tmp/qwikboard-XXXXXX")
end
function fs.getmd5(filename)
local md5_file_name = filename .. ".md5"
os.execute(qb.md5sum .. " " .. filename .. " > " .. md5_file_name)
local md5fh = assert(io.open(md5_file_name), "r")
local md5f_str = md5fh:read("*all")
md5fh:close()
return string.match(md5f_str, "^(%w+)%s")
end
--- Message database ---
function Message(msg)
msgs[msg.number] = msg
end
local function get_next_msg_number(conf_num)
local msgnum_filename = qb.path .. "/conferences/" ..
qb.conferences[conf_num+1] .. ".next"
local mnf = assert(io.open(msgnum_filename, "r"))
local next_num = tonumber(mnf:read("*all"))
mnf:close()
return next_num
end
local function set_next_msg_number(conf_num, next_num)
local msgnum_filename = qb.path .. "/conferences/" ..
qb.conferences[conf_num+1] .. ".next"
local mnf = assert(io.open(msgnum_filename, "w"))
mnf:write(next_num)
mnf:close()
end
local function append_new_msg(conf_num, msg)
local cf = assert(io.open(qb.path .. "/conferences/" ..
qb.conferences[conf_num+1] .. ".msgs", "a+"))
cf:write("\n")
cf:write("Message{\n")
cf:write("\tnumber=" .. msg.number .. ",\n")
cf:write("\tto=" .. string.format("%q", msg.to) .. ",\n")
cf:write("\tfrom=" .. string.format("%q", msg.from) .. ",\n")
cf:write("\tsubject=" .. string.format("%q", msg.subject) .. ",\n")
if msg.replyto then
cf:write("\treplyto=" .. msg.replyto .. ",\n")
end
cf:write("\tdate=" .. string.format("%q", msg.date) .. ",\n")
cf:write("\ttime=" .. string.format("%q", msg.time) .. ",\n")
cf:write("\ttext=" .. string.format("%q",msg.text) .. "\n")
cf:write("}\n")
cf:close()
end
local function export_messages(handle, output_file_name)
local fh = assert(io.open(output_file_name, "w"))
for cnum,cname in ipairs(qb.conferences) do
msgs = {}
dofile(qb.path .. "/conferences/" .. cname .. ".msgs")
for i,msg in ipairs(msgs) do
if string.match(string.lower(msg.from), "^%s*" .. string.lower(handle) .. "%s*$") then
fh:write("MessageArea: " .. cname .. "\n")
fh:write("MessageNum: " .. msg.number .. "\n")
fh:write("From: " .. msg.from .. "\n")
fh:write("To: " .. msg.to .. "\n")
fh:write("Subject: " .. msg.subject .. "\n")
fh:write("Date: " .. msg.date .. "\n")
fh:write("Time: " .. msg.time .. "\n")
fh:write("---\n")
fh:write(msg.text .. "\n\n")
end
end
end
end
--- QWK formatting ---
local qwk = {}
function qwk.build_qwk(user_name, qwk_filename, first_unseen_msgs)
first_unseen_msgs = first_unseen_msgs or {}
local work_dir = fs.mktempdir()
-- CONTROL.DAT
qwk.write_control(work_dir, user_name)
-- Copy BBS welcome, news and goodbye files
fs.copy(qb.path .. "/notices/hello", work_dir .. "/HELLO", true)
fs.copy(qb.path .. "/notices/news", work_dir .. "/NEWS", true)
fs.copy(qb.path .. "/notices/goodbye", work_dir .. "/GOODBYE", true)
-- Pack messages
first_unseen_msgs = qwk.write_message(work_dir, first_unseen_msgs)
-- Create archive in outgoing
os.execute(qb.zip .. " -rj " .. qwk_filename .. " " .. work_dir)
fs.rmdir(work_dir, true)
return first_unseen_msgs
end
function qwk.write_control(target_dir, user_name)
local cf = assert(io.open(target_dir .. "/CONTROL.DAT", "w"))
cf:write(qb.name .. "\r\n")
cf:write("\r\n") -- BBS location
cf:write("\r\n") -- BBS phone number
cf:write(qb.sysop .. "\r\n")
cf:write("12345," .. qb.bbsid .. "\r\n")
cf:write(os.date("%d-%m-%Y,%X") .. "\r\n") -- packet creation time
cf:write(user_name .. "\r\n")
cf:write("\r\n")
cf:write(0 .. "\r\n")
cf:write(0 .. "\r\n") -- TODO: Number of messages in packet
cf:write(#qb.conferences-1 .. "\r\n") -- Index of final conference
for i,v in ipairs(qb.conferences) do
cf:write(i-1 .. "\r\n")
cf:write(v .. "\r\n")
end
cf:write("HELLO\r\n")
cf:write("NEWS\r\n")
cf:write("GOODBYE\r\n")
cf:close()
end
function qwk.write_message (target_dir, first_unseen_msgs)
local mf = assert(io.open(target_dir .. "/MESSAGES.DAT", "w"))
-- Also write MultiMail compatible RED file
-- (For some reson, MM doesn't respect read status in header.)
local rf = assert(io.open(target_dir .. "/" .. qb.bbsid .. ".RED", "w"))
print_object(first_unseen_msgs)
local pkt_msg_num = 0
mf:write(space_pad("Produced by QWiKBoard.", 128))
for cnum,cname in ipairs(qb.conferences) do
msgs = {}
dofile(qb.path .. "/conferences/" .. cname .. ".msgs")
for i,msg in ipairs(msgs) do
pkt_msg_num = pkt_msg_num + 1
if first_unseen_msgs[cnum]
and msg.number