#!/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 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 --- local msgs = {} function Message(msg) msgs[msg.number] = msg 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.read_message(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.read_message_header(mf, obbs) end end function qwk.read_message_header(mf, obbs) 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), " ", "")) msg.from = string.lower(string.gsub(mf:read(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") 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" -- max 8 char obbs.sysop = "Sysop Name" -- obbs.hello_file = "hello" -- obbs.news_file = "news" -- obbs.goodbye_file = "goodbye" obbs.conferences = { "Announcements", "General", "Meta" } ]] end -- Initialise conference directory structure function cmd.init () load_config() if fs.exists(obbs.path .. "/conferences") or fs.exists(obbs.path .. "/outgoing") then print("One or more OBBS directories already exist. Aborting.") return end fs.mkdir(obbs.path .. "conferences") for i,v in ipairs(obbs.conferences) do fs.touch(obbs.path .. "conferences/" .. v .. ".msgs") end fs.mkdir(obbs.path .. "outgoing") end -- qwkout: generate QWK file function cmd.qwkout () load_config() if not arg[1] then print "Usage: obbs qwkout USER" return end local user_name = arg[1] local work_dir = fs.mktempdir() -- CONTROL.DAT qwk.write_control(work_dir, obbs, user_name) -- Copy BBS welcome, news and goodbye files if obbs.hello and fs.exists(obbs.hello) then fs.copy(obbs.hello, work_dir .. "/HELLO") else fs.touch(work_dir .. "/HELLO") end if obbs.news and fs.exists(obbs.news) then fs.copy(obbs.news, work_dir .. "/NEWS") else fs.touch(work_dir .. "/NEWS") end if obbs.news and fs.exists(obbs.goodbye) then fs.copy(obbs.goodbye, work_dir .. "/GOODBYE") else fs.touch(work_dir .. "/GOODBYE") end -- Pack messages qwk.write_message(work_dir, obbs) -- Create archive in outgoing os.execute("zip -rj " .. obbs.path .. "/outgoing/" .. obbs.bbsid .. ".qwk " .. 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 FILE USER" 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("unzip " .. work_dir .. "/repfile.rep -d " .. work_dir) qwk.read_message(work_dir, obbs, user_name) fs.rmdir(work_dir, true) 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() .