#!/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() fs.rmfile(md5_file_name) return string.match(md5f_str, "^(%w+)%s") end --- Message database --- function Message(msg) msgs[msg_index] = msg msg_index = msg_index + 1 end local function load_messages(conf_name) msg_index = 1 msgs = {} dofile(qb.path .. "/conferences/" .. conf_name .. ".msgs") 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") load_messages(cname) 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 fh:close() end local function mirror_new_msg(mirror_dir, msg) local fname = msg.number .. "--" .. msg.date .. "-" .. msg.from .. ".txt" local fh = assert(io.open(mirror_dir .. "/" .. fname, "w")) local header = msg.subject .. " (" .. msg.from .. ", " .. msg.date .. ")" fh:write(header .. "\n") fh:write(string.rep("-",#header) .. "\n\n") fh:write(msg.text .. "\n") fh:close() 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") load_messages(cname) for i,msg in ipairs(msgs) do pkt_msg_num = pkt_msg_num + 1 if first_unseen_msgs[cnum] and msg.number