#!/usr/bin/env lua -- OBBS: An Offline-first BBS Utility -- 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: obbs [-c config_file] COMMAND arguments Here COMMAND is one of: newcfg init qwkout repin nncp_handler You can also use the -c option to specify an alternative configuration file. (The default is the file "config.lua" in the current directory.)]] obbs = {} local config_file = "config.lua" local function load_config () 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.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.copy(filename, destfilename) 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/obbs-XXXXXX") end --- Message database parsing --- function Message(msg) msgs[msg.number] = msg end local function get_next_msg_number(obbs, conf_num) local msgnum_filename = obbs.path .. "/conferences/" .. obbs.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(obbs, conf_num, next_num) local msgnum_filename = obbs.path .. "/conferences/" .. obbs.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(obbs, conf_num, msg) local cf = assert(io.open(obbs.path .. "/conferences/" .. obbs.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.write_control(target_dir, obbs, user_name) local cf = assert(io.open(target_dir .. "/CONTROL.DAT", "w")) cf:write(obbs.name .. "\r\n") cf:write("\r\n") -- BBS location cf:write("\r\n") -- BBS phone number cf:write(obbs.sysop .. "\r\n") cf:write("12345," .. obbs.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(#obbs.conferences-1 .. "\r\n") -- Index of final conference for i,v in ipairs(obbs.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, obbs) local mf = assert(io.open(target_dir .. "/MESSAGES.DAT", "w")) local pkt_msg_num = 0 mf:write(space_pad("Produced by OBBS.", 128)) for cnum,cname in ipairs(obbs.conferences) do msgs = {} dofile(obbs.path .. "/conferences/" .. cname .. ".msgs") for i,msg in ipairs(msgs) do pkt_msg_num = pkt_msg_num + 1 qwk.write_message_header_block(mf, obbs, cnum, pkt_msg_num, msg) qwk.write_message_text(mf, obbs, msg) end end mf:close() end function qwk.write_message_header_block(mf, obbs, 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, obbs, 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.process_replies(archive_dir, obbs, user_name) local msg_filename = archive_dir .. "/" .. obbs.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, obbs.bbsid .. " *") then print("Error: Failed to match BBSID in first block.") return false end while mf:read(0) do qwk.process_reply(mf, obbs, user_name) end end function qwk.process_reply(mf, obbs, 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(obbs, conf_num) set_next_msg_number(obbs, conf_num, msg.number+1) append_new_msg(obbs, conf_num, msg) print("Processed message for conference " .. obbs.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 () print [[ -- OBBS Configuration File obbs.path = "." -- Path to OBBS root obbs.name = "My OBBS Name" obbs.bbsid = "MYOBBSID" -- upper case, max 8 char obbs.sysop = "Sysop Name" obbs.conferences = { "Announcements", "General", "Meta" } -- Ensure these point to zip and unzip on your system obbs.zip = "/usr/bin/zip" obbs.unzip = "/usr/bin/unzip" ]] end -- Initialise conference directory structure function cmd.init () load_config() if fs.exists(obbs.path .. "/conferences") or fs.exists(obbs.path .. "/notices") then print("One or more OBBS directories already exist. Aborting.") return false end fs.mkdir(obbs.path .. "/conferences") for i,v in ipairs(obbs.conferences) do fs.touch(obbs.path .. "/conferences/" .. v .. ".msgs") io.open(obbs.path .. "/conferences/" .. v .. ".next", "w"):write(1) end fs.mkdir(obbs.path .. "/notices") fs.touch(obbs.path .. "/notices/hello") fs.touch(obbs.path .. "/notices/news") fs.touch(obbs.path .. "/notices/goodbye") end -- qwkout: generate QWK file function cmd.qwkout () load_config() if not arg[2] then print [[Usage: obbs 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 local user_name = arg[1] local qwk_filename = arg[2] local work_dir = fs.mktempdir() -- CONTROL.DAT qwk.write_control(work_dir, obbs, user_name) -- Copy BBS welcome, news and goodbye files fs.copy(obbs.path .. "/notices/hello", work_dir .. "/HELLO") fs.copy(obbs.path .. "/notices/news", work_dir .. "/NEWS") fs.copy(obbs.path .. "/notices/goodbye", work_dir .. "/GOODBYE") -- Pack messages qwk.write_message(work_dir, obbs) -- Create archive in outgoing os.execute(obbs.zip .. " -rj " .. obbs.path .. "/" .. qwk_filename .. " " .. work_dir) fs.rmdir(work_dir, true) end -- repin: read REP file in function cmd.repin() load_config() if not arg[2] then print [[Usage: obbs repin INFILE USER 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 obbs doesn't care about this.]] return end local rep_filename = arg[1] local user_name = arg[2] if not fs.exists(rep_filename) then print("Error: reply packet file '" .. rep_filename .. "' does not exist.") return end local work_dir = fs.mktempdir() fs.copy(rep_filename, work_dir .. "/repfile.rep") os.execute(obbs.unzip .. " " .. work_dir .. "/repfile.rep -d " .. work_dir) qwk.process_replies(work_dir, obbs, user_name) fs.rmdir(work_dir, true) end function cmd.nncp_handler() load_config() local user_name = os.getenv("NNCP_SENDER") if not user_name 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: { obbs: ["/usr/bin/obbs -c /var/share/obbs/config.lua nncp"] }]] end -- Main local function main() if #arg > 2 and arg[1]=="-c" then config_file = arg[2] table.remove(arg, 1) table.remove(arg, 1) end if #arg < 1 then print(usage) else 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 main() .