#!/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 print_table (t, lev) lev = lev or 0 local ind = string.rep("\t", lev) for k,v in pairs(t) do if type(v)=="table" then print(ind ..k .. "= {") print_table(v, lev+1) print(ind .. "}") else print(ind .. k .. "=" .. tostring(v)) end end 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 --- Message database parsing --- 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 --- QWK formatting --- local qwk = {} function qwk.build_qwk(user_name, qwk_filename) 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 qwk.write_message(work_dir) -- Create archive in outgoing os.execute(qb.zip .. " -rj " .. qwk_filename .. " " .. work_dir) fs.rmdir(work_dir, true) 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) local mf = assert(io.open(target_dir .. "/MESSAGES.DAT", "w")) 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 qwk.write_message_header_block(mf, cnum, pkt_msg_num, msg) qwk.write_message_text(mf, msg) end end mf:close() end function qwk.write_message_header_block(mf, conf_num, pkt_msg_num, msg) mf:write(" ") -- Message status flag mf:write(space_pad(tostring(msg.number), 7)) mf:write(msg.date) -- mm-dd-yy mf:write(msg.time) -- hh:mm mf:write(space_pad(string.upper(msg.to), 25)) mf:write(space_pad(string.upper(msg.from), 25)) mf:write(space_pad(msg.subject, 25)) mf:write(string.rep(" ", 12)) -- Password (blank) if msg.reply_to then mf:write(space_pad(tostring(msg.reply_to), 8)) else mf:write(string.rep(" ", 8)) end nblocks = math.ceil(string.len(msg.text)/128) + 1 mf:write(space_pad(tostring(nblocks), 6)) -- number of blocks including header mf:write("\xe1") -- flag indicating message is "active" (not "to be killed") mf:write(string.pack("I2",conf_num-1)) mf:write(string.pack("I2",pkt_msg_num)) mf:write(" ") -- No network tag-line present end function qwk.write_message_text(mf, msg) mf:write((string.gsub(msg.text, "\n", "\xe3"))) local padding = (128 - (string.len(msg.text) % 128)) % 128 mf:write(string.rep("\0",padding)) end function qwk.import_rep(user_name, rep_filename) if not fs.exists(rep_filename) then print("Error: reply packet file '" .. rep_filename .. "' does not exist.") return false end local archive_dir = fs.mktempdir() fs.copy(rep_filename, archive_dir .. "/repfile.rep") os.execute(qb.unzip .. " " .. archive_dir .. "/repfile.rep -d " .. archive_dir) local msg_filename = archive_dir .. "/" .. qb.bbsid .. ".MSG" if not fs.exists(msg_filename) then print("Error: MSG file not found.") return false end local mf = assert(io.open(msg_filename, "r")) local block = mf:read(128) if not string.find(block, qb.bbsid .. " *") then print("Error: Failed to match BBSID in first block.") return false end while mf:read(0) do qwk.process_reply(mf, user_name) end fs.rmdir(archive_dir, true) end function qwk.process_reply(mf, user_name) local msg = {} mf:read(1) -- Message status (ignore for now) local conf_num = tonumber(mf:read(7)) msg.date = mf:read(8) msg.time = mf:read(5) msg.to = string.lower(string.gsub(mf:read(25), " ", "")) mf:read(25) -- take user name from provided user name instead msg.from = string.lower(string.sub(user_name, 1, 25)) msg.subject = string.gsub(mf:read(25), " ", "") mf:read(12) -- Password (unused) msg.replyto = tonumber(mf:read(8)) local nblocks = tonumber(mf:read(6)) mf:read(1) -- flag (ignore for now) mf:read(2) -- conference number (already have this) mf:read(2) -- message number in packet (unused) mf:read(1) -- network tag flag (ignore) msg.text = "" for b=2,nblocks do msg.text = msg.text .. mf:read(128) end msg.text = string.gsub(msg.text, "\xe3", "\n") msg.number = get_next_msg_number(conf_num) set_next_msg_number(conf_num, msg.number+1) append_new_msg(conf_num, msg) print("Processed message for conference " .. qb.conferences[conf_num+1] .. ".") -- print("Message{") -- for k,v in pairs(msg) do -- print("\t" .. k .. "=\'" .. v .. "',") -- end -- print("}") end --- Commands --- local cmd = {} -- Send default configuration to stdout function cmd.newcfg () local config_file = qb.path .. "/config.lua" if fs.exists(config_file) then print("Configuration file '" .. config_file .. "' already exists. Aborting.") return false end print("Writing new configuration file " .. config_file .. "...") local cf = assert(io.open(config_file, "w")) cf:write([[ -- QWiKBoard Configuration File qb.name = "My BBS Name" qb.bbsid = "MYBBSID" -- upper case, max 8 char qb.sysop = "Sysop Name" qb.conferences = { "Announcements", "General", "Meta" } -- Ensure these point to zip and unzip on your system qb.zip = "/usr/bin/zip" qb.unzip = "/usr/bin/unzip" qb.nncp_file = "/usr/bin/nncp-file" ]]) cf:close() end -- Initialise conference directory structure function cmd.init () load_config() if fs.exists(qb.path .. "/conferences") or fs.exists(qb.path .. "/notices") then print("One or more QWiKBoard directories already exist. Aborting.") return false end fs.mkdir(qb.path .. "/conferences") for i,v in ipairs(qb.conferences) do fs.touch(qb.path .. "/conferences/" .. v .. ".msgs") io.open(qb.path .. "/conferences/" .. v .. ".next", "w"):write(1) end fs.mkdir(qb.path .. "/notices") fs.touch(qb.path .. "/notices/hello") fs.touch(qb.path .. "/notices/news") fs.touch(qb.path .. "/notices/goodbye") fs.touch(qb.path .. "/nncp_users.lua") end -- qwkout: generate QWK file function cmd.qwkout () load_config() if not arg[2] then print [[Usage: qwikboard DIR qwkout USER OUTFILE Here OUTFILE is is the name of the output QWK file relative to the current directory. The QWK file is traditionally named BBSID.QWK, with BBSID being the 8 character ID of the BBS.]] return end qwk.build_qwk(arg[1], arg[2]) end -- repin: read REP file in function cmd.repin() load_config() if not arg[2] then print [[Usage: qwikboard DIR repin USER INFILE Here INFILE is the name of the REP file to import new messages from. This is traditionally named BBSID.rep, where BBSID is the 8 character max ID of the BBS, but QWiKBoard doesn't care about this.]] return end qwk.import_rep(arg[1], arg[2]) end local function restore_nncp_usernames() nncp_usernames = {} dofile(qb.path .. "/nncp_users.lua") end local function store_nncp_usernames() local fh = assert(io.open(qb.path .. "/nncp_users.lua", "w")) for k,v in pairs(nncp_usernames) do fh:write("nncp_usernames[" .. string.format("%q",k) .. "] = " .. string.format("%q", v) .. "\n") end end function cmd.nncp_handler() load_config() local node_id = os.getenv("NNCP_SENDER") if not node_id then print [[The nncp_handler command is intended to be called by nncp on receipt of an exec packet. For instance, the offline BBS could be made accessible by user (node) NODENAME by adding the following to their neigh entry in the /etc/nncp.hjson file exec: { offline: ["/usr/local/bin/qwikboard", "/var/bbs/" "nncp_handler"] }]] return false end restore_nncp_usernames() local input_string = io.stdin:read("*all") if string.match(input_string, "^get%s%w+%s$") then local user_name = string.match(input_string, "^get%s(%w+)%s$") nncp_usernames[node_id] = user_name store_nncp_usernames() print("Building QWK packet for user '" .. user_name .. "'") -- Construct QWK packet local out_file_name = os.tmpname() fs.rmfile(out_file_name) local out_file_name = out_file_name .. ".qwk" qwk.build_qwk(user_name, out_file_name) print(qb.nncp_file .. " " .. out_file_name .. " " .. node_id .. ":" .. qb.bbsid .. ".QWK") os.execute(qb.nncp_file .. " " .. out_file_name .. " " .. user_name .. ":" .. qb.bbsid .. ".QWK") fs.rmfile(out_file_name) else local user_name = nncp_usernames[node_id] if not user_name then print("No user name for node " .. node_id .. " found. Aborting.") return false end print("Accepting REP packet from user '" .. user_name .. "'") -- Accept REP packet local rep_file_name = os.tmpname() fs.rmfile(rep_file_name) local rep_file_name = rep_file_name .. ".rep" local rf = assert(io.open(rep_file_name, "w")) rf:write(input_string) rf:close() qwk.import_rep(user_name, rep_file_name) fs.rmfile(rep_file_name) end end -- Main local function main() if #arg < 2 then print(usage) else if not fs.direxists(arg[1]) then print("Directory '" .. arg[1] .. "' does not exist. Aborting.") else qb.path = arg[1] table.remove(arg, 1) if cmd[arg[1]] then local f = cmd[arg[1]] table.remove(arg,1) f() else print("Unknown command '" .. arg[1] .. "'") print(usage) end end end end main() .