#!/usr/bin/env lua
telemver = "0.4 // 2019-04-27"
require("pl.strict")
file = require("pl.file")
io = require("io")
lfs = require("lfs")
os = require("os")
path = require("pl.path")
string = require("string")
stringx = require("pl.stringx")
table = require("table")
unistd = require("posix.unistd")
_BBS_ROOT = "/var/bbs/"
_EDITOR = os.getenv("BBSED")
if not _EDITOR then
_EDITOR = os.getenv("VISUAL")
end
if not _EDITOR then
_EDITOR = os.getenv("EDITOR")
end
if not _EDITOR then
_EDITOR = "nano"
end
_PAGER = os.getenv("PAGER")
if not _PAGER then
_PAGER = "less"
end
_COLOURS = {
red=31,
green=32,
yellow=33,
blue=34,
magenta=35,
cyan=36,
}
-- Global var declarations
username = arg[1]
boards = {} -- Array of board tables, alphaetically sorted
boards_by_name = {} -- Set of board names, values are tables, must always be consistent with boards
current_board = nil -- Active board table
current_board_threads = {} -- Array of thread tables, containing threads associated with current_board
current_thread = {} -- Active thread table, always an element of current_board_threads
current_thread_posts = {} -- Array of post tables, containing posts associated with current_thread
current_post_index = nil -- Integer index into current_thread_posts
colours = true -- Boolean, controls whether to use ANSI colours
reversemessages = false -- Boolean, controls message sort order
hideold = false -- Boolean, controls whether to hide old threads
-- Setuid stuff
bbs_uid = unistd.geteuid()
user_uid = unistd.getuid()
function drop_privs()
unistd.setpid("U", user_uid)
end
function raise_privs()
unistd.setpid("U", bbs_uid)
end
drop_privs()
-- Utility functions
function cat_file(filename)
io.write(file.read(filename))
end
function getchar()
os.execute("/bin/stty -icanon")
local char = io.read(1)
os.execute("/bin/stty icanon")
return char
end
function colourise(colour, str)
if not colours or _COLOURS[colour] == nil then return str end
return string.char(27) .. "[" .. tostring(_COLOURS[colour]) .. "m" .. str .. string.char(27) .. "[0m"
end
function dispatch_loop(dispatch_table, quit_char, prompt_func, err_str)
repeat
-- Show prompt and read 1 char from keyboard
io.write(prompt_func())
local c = getchar()
if c ~= "\n" then io.write("\n") end
-- Use char as index into dispatch table
if c == "\n" then
-- pass
elseif dispatch_table[c] == nil then
print(err_str)
else
dispatch_table[c]()
end
until c == quit_char
end
-- Internals
function update_boards()
for boarddir in lfs.dir(path.join(_BBS_ROOT, "boards")) do
if string.sub(boarddir, 1, 1) ~= "." and not boards_by_name[boarddir] then
local board = {}
board.name = boarddir
board.directory = path.join(_BBS_ROOT, "boards", boarddir)
board.topic = file.read(path.join(board.directory, "topic"))
board.last_scanned = 0
boards_by_name[board.name] = board
table.insert(boards, board)
end
end
table.sort(boards, function(x,y) return x.name < y.name end)
end
function check_at_board()
if current_board == nil then
print("Not at any board! Hit `l` to list boards, `g` to go to one.")
end
return current_board ~= nil
end
function get_threads(board)
local threads = {}
for threaddir in lfs.dir(board.directory) do
if string.sub(threaddir, 1,1) == "." then goto continue end
if threaddir == "topic" then goto continue end
local thread = {}
thread.directory = path.join(board.directory, threaddir)
local _, _, timestamp, author = string.find(threaddir, "(%d+)-(%g+)")
thread.timestamp = tonumber(timestamp)
thread.author = author
io.input(path.join(thread.directory, "subject"))
thread.subject = io.read("*line")
io.input(io.stdin)
local posts = get_posts(thread)
thread.post_count = #posts
thread.updated = 0
thread.updated_by = ""
for _, post in ipairs(posts) do
if post.timestamp > thread.updated then
thread.updated = post.timestamp
thread.updated_by = post.author
end
end
if hideold then
local age = os.time() - thread.updated
if age < 7776000 then -- 90 days, in seconds
table.insert(threads, thread)
end
else
table.insert(threads, thread)
end
::continue::
end
if reversemessages then
table.sort(threads, function(x,y) return x.updated < y.updated end)
else
table.sort(threads, function(x,y) return x.updated > y.updated end)
end
return threads
end
function get_posts(thread)
local posts = {}
for postfile in lfs.dir(thread.directory) do
if string.sub(postfile, 1,1) == "." then goto continue end
if postfile == "subject" then goto continue end
local post = {}
post.filename = path.join(thread.directory, postfile)
local _, _, timestamp, author = string.find(postfile, "(%d+)-(%g+)")
post.timestamp = tonumber(timestamp)
post.author = author
table.insert(posts, post)
::continue::
end
table.sort(posts, function(x,y) return x.timestamp < y.timestamp end)
return posts
end
function load_scan_times()
local scanfile = path.join(_BBS_ROOT, "scans", username ..".scan")
raise_privs()
local f, err = io.open(scanfile, "r")
if f == nil then return end
for line in f:lines() do
local _, _, board, scantime = string.find(line, "(%w+):(%d+)")
if boards_by_name[board] then
boards_by_name[board].last_scanned = tonumber(scantime)
end
end
f:close()
drop_privs()
end
function save_scan_times()
local scanfile = path.join(_BBS_ROOT, "scans", username ..".scan")
raise_privs()
local f, err = io.open(scanfile, "w")
for _, board in ipairs(boards) do
f:write(board.name .. ":" .. tostring(board.last_scanned) .. "\n")
end
f:close()
drop_privs()
end
-- Commands
function do_board()
-- Creates a new (empty) board
-- Get details
io.write("New board name? (Max 18 chars) ")
local board = string.upper(io.read())
if string.len(board) > 18 then
print("Board names must be 18 characters or less!")
return
elseif string.find(board, "%p") or string.find(board, "%s") then
print("Board names may not contain spaces or punctuation!")
return
elseif string.len(board) == 0 then
print("New board cancelled.")
return
elseif boards_by_name[board] ~= nil then
print("Board " .. board .. " already exists!")
return
end
io.write("Board topic? (Max 48 chars)")
local desc = io.read()
if string.len(desc) > 48 then
print("Board topics must be 48 characters or less!")
return
end
-- Create directory
raise_privs()
local board_dir = path.join(_BBS_ROOT, "boards", board)
lfs.mkdir(board_dir)
os.execute("chmod og+rx " .. board_dir)
-- Write topic file
local topic_file = path.join(board_dir, "topic")
file.write(topic_file, desc)
os.execute("chmod og+r " .. topic_file)
drop_privs()
-- Update representation of BBS
update_boards()
-- Done!
print("Board created.")
end
function do_colour()
if colours then
colours = false
print("Coloured text disabled.")
else
colours = true
print(colourise("red", "Coloured text enabled."))
end
end
function do_order()
if reversemessages then
reversemessages = false
print("Newest messages displayed at top.")
else
reversemessages = true
print("Newest messages displayed at bottom.")
end
end
function do_hide()
if hideold then
hideold = false
print("Showing old threads.")
else
hideold = true
print("Hiding old threads.")
end
end
function do_go()
io.write("Go to which board? (use name or index) ")
local board = string.upper(io.read())
if board == "" then
do_list()
elseif boards_by_name[board] ~= nil then
current_board = boards_by_name[board]
current_board_threads = get_threads(current_board)
elseif tonumber(board) == nil then
print("No such board! Hit `l` to list boards.")
else
local index = tonumber(board)
if index < 1 or index > #boards then
print("No such board! Hit `l` to list boards.")
else
current_board = boards[index]
current_board_threads = get_threads(current_board)
end
end
end
function do_help()
print([[
Telem BBS client usage:
[Bulletin Board Commands]
------------------------------------------------------------
(l)ist ......... List all available boards
(g)oto ......... Goto a board by name or index
(m)essages ..... List all messages in current board
(s)can ......... Scan for new messages since last scan/login
(t)ype ......... Display the contents of a message thread
(r)eply ........ Reply to a message thread
(R)ecent ....... List most recently updated messages
(n)ew .......... Start a new thread in current board
(b)oard ........ Create a new board
(/) ............ Search thread subjects
[User Commands]
------------------------------------------------------------
(h)elp ......... Bring up this basic help menu
(?) ............ Expanded help and information
(!) ............ A few simple rules of use for this board
(c)olor ........ Disable/Enable ANSI color codes
(H)ide ......... Disable/Enable hiding of old threads
(o)rder ........ Toggle order of message listing
(w)ho .......... List currently logged-in users
(q)uit ......... Immediately quit the board
]])
end
function do_help2()
os.execute("man 1 telem")
end
function do_list()
update_boards()
print(colourise("magenta", string.format("%s %s %s %s", stringx.ljust("ID",3),
stringx.ljust("Board name", 20),
stringx.ljust("Board topic" ,50), "Thread count"))
)
print(string.rep("-",79))
for i,board in ipairs(boards) do
local threads = -3 -- Don't want to count "topic" file or "." or ".."
for thread in lfs.dir(board.directory) do
threads = threads +1
end
print(string.format("%3d %s %s [%3d threads]", i,
colourise("cyan", stringx.ljust(board.name,20)),
stringx.ljust(board.topic,50), threads)
)
end
print(string.rep("-",79))
end
function do_messages()
if not check_at_board() then return end
current_board_threads = get_threads(current_board)
if #current_board_threads == 0 then
print("Empty board!")
return
end
-- Headers
print(colourise("magenta", string.format("%s %s %s %s", stringx.ljust("ID",3), stringx.ljust("Created", 8),
stringx.ljust("Creator",16), stringx.ljust("Subject",50))))
-- Separator
print(string.rep("-",79))
-- Messages
for i, thread in ipairs(current_board_threads) do
local updated_str = ""
if thread.updated > current_board.last_scanned and thread.updated_by ~= username then
updated_str = " (*)"
end
print(string.format("%3d %s %s %s [%3d posts]", i, os.date("%x", thread.timestamp),
colourise("yellow", stringx.ljust(thread.author,16)),
stringx.ljust(thread.subject .. updated_str,50),
thread.post_count))
end
-- Separator
print(string.rep("-",79))
-- Update scan times
current_board.last_scanned = os.time()
save_scan_times()
end
function create_post()
-- This is used by both do_new and do_reply
-- Launch editor to save post in temporary file
local filename = os.tmpname()
::edit::
os.execute(_EDITOR .. " " .. filename)
-- Check that file exists and is not empty
if not path.exists(filename) then return nil end
if lfs.attributes(filename).size == 0 then
file.delete(filename)
return nil
end
-- Get confirmation from user
print("PREVIEW:")
print(string.rep("-",79))
cat_file(filename)
print(string.rep("-",79))
local valid_chars = { y=true, n=true, e=true }
local c = nil
while not valid_chars[c] do
io.write("Post this? [y]es, [n]o, [e]dit ")
c = string.lower(getchar())
print("")
end
if c == "e" then
goto edit
elseif c == "n" then
file.delete(filename)
return nil
else
-- Make sure the telem program can read this file once
-- it sets the euid to bbs.
os.execute("chmod og+r " .. filename)
return filename
end
end
function do_new()
if not check_at_board() then return end
-- Get subject for new thread
io.write("Subject? ")
local subject = io.read()
if string.len(subject) > 48 then
print("Thread subjects must be 48 characters or less!")
return
elseif string.len(subject) == 0 then
print("New thread cancelled.")
return
end
-- Save body of post to temp file
local filename = create_post()
if not filename then
print("Post cancelled.")
return
end
-- TODO: show and confirm
-- Make thread dir
local timestamp = tostring(os.time())
local thread_dir = timestamp .. "-" .. username
local thread_path = path.join(current_board.directory, thread_dir)
raise_privs()
lfs.mkdir(thread_path)
os.execute("chmod og+rx " .. thread_path)
-- Write subject file
file.write(path.join(thread_path, "subject"), subject)
os.execute("chmod og+r " .. path.join(thread_path, "subject"))
-- Move post file
local post_file = thread_dir -- first post and thread directory names are the same!
local newpath = path.join(thread_path, post_file)
-- Copy first - bbs user doesn't have permissions to delete
local ret, str = file.copy(filename, newpath)
if not ret then
print(str)
end
os.execute("chmod og+r " .. newpath)
drop_privs()
-- Delete file to complete the move
file.delete(filename)
-- Done!
print("Post submitted.")
end
function do_recent()
local recent = filter_latest_posts(function(x) return true end, 10)
print("Most recently active threads:")
print(colourise("magenta", string.format("%s %s %s",
stringx.ljust("Board name", 20),
stringx.ljust("Thread topic" ,48),
"Latest post"))
)
print(string.rep("-",79))
for i, update in ipairs(recent) do
io.write(colourise("cyan", stringx.ljust(update.board.name,20)) .. " " ..
stringx.ljust(update.thread.subject, 48) .. " " ..
os.date("%B %d, %Y", update.thread.updated) .. "\n")
end
print(string.rep("-",79))
end
function filter_latest_posts(filter, N)
local matches = {}
for i, board in ipairs(boards) do
local threads = get_threads(board)
for _, thread in ipairs(threads) do
local x = {}
x.board = board
x.thread = thread
if filter(x) == nil then
-- pass
elseif #matches < N then
table.insert(matches, x)
table.sort(matches, function(x,y) return x.thread.updated > y.thread.updated end)
elseif thread.updated > matches[N].thread.updated then
table.remove(matches, N)
table.insert(matches, x)
table.sort(matches, function(x,y) return x.thread.updated > y.thread.updated end)
end
end
end
return matches
end
function do_scan()
for i, board in ipairs(boards) do
io.write(string.format("%3d ", i))
io.write("Scanning " .. colourise("cyan", stringx.ljust(board.name,20)))
local threads = get_threads(board)
local updated_threads = 0
for _, thread in ipairs(threads) do
if thread.updated > board.last_scanned and thread.updated_by ~= username then
updated_threads = updated_threads + 1
end
end
if updated_threads == 0 then
print("No new posts")
else
print(colourise("green", tostring(updated_threads) .. " thread(s) updated!"))
end
end
end
function do_search()
-- Get search term
io.write("Search term? ")
local searchterm = io.read()
if string.len(searchterm) == 0 then
print("Search cancelled.")
return
end
local hits = filter_latest_posts(function(x)
return string.find(string.lower(x.thread.subject), string.lower(searchterm))
end, 10)
if #hits == 0 then
print("No hits found.")
return
end
print("Most recently active matching threads:")
print(colourise("magenta", string.format("%s %s %s",
stringx.ljust("Board name", 20),
stringx.ljust("Thread topic" ,48),
"Latest post"))
)
print(string.rep("-",79))
for i, update in ipairs(hits) do
io.write(colourise("cyan", stringx.ljust(update.board.name,20)) .. " " ..
stringx.ljust(update.thread.subject, 48) .. " " ..
os.date("%B %d, %Y", update.thread.updated) .. "\n")
end
print(string.rep("-",79))
end
-- The "type" command is implemented using multiple functions
-- do_type is the entry point, which runs a command dispatch loop that
-- runs various do_type_xxx functions.
function do_type_first()
current_post_index = 1
do_type_show_post()
end
function do_type_last()
current_post_index = #current_thread_posts
do_type_show_post()
end
function do_type_next()
if current_post_index ~= #current_thread_posts then
current_post_index = current_post_index + 1
end
do_type_show_post()
end
function do_type_prev()
if current_post_index ~= 1 then
current_post_index = current_post_index - 1
end
do_type_show_post()
end
function do_type_reply()
-- Read reply body into temp file
local filename = create_post()
if not filename then
print("Reply cancelled.")
return
end
-- Move temp file to correct location and set permissions
local timestamp = tostring(os.time())
local newfilename = timestamp .. "-" .. username
local newpath = path.join(current_thread.directory, newfilename)
raise_privs()
local ret, str = file.copy(filename, newpath)
if not ret then
print(str)
end
os.execute("chmod og+r " .. newpath)
drop_privs()
file.delete(filename)
-- Update state and show reply
current_thread_posts = get_posts(current_thread)
current_post_index = #current_thread_posts
do_type_show_post()
end
function do_type_save()
io.write("Save thread to file: ")
local filename = io.read()
if string.len(filename) == 0 then
print("Saving cancelled.")
return
end
local ret, err = do_type_savefile(filename)
if ret == nil then print(err) end
end
function do_type_whole()
local filename = os.tmpname()
do_type_savefile(filename)
os.execute(_PAGER .. " " .. filename)
file.delete(filename)
end
function do_type_show_post()
local post = current_thread_posts[current_post_index]
print("SUBJECT: " .. current_thread.subject)
print("AUTHOR: " .. post.author)
print("POSTED: " .. os.date("%H:%M %B %d, %Y", post.timestamp))
print("--------------------------------")
cat_file(post.filename)
print("--------------------------------")
print(string.format("Viewing post %d of %d in thread", current_post_index, #current_thread_posts))
end
function do_type_savefile(filename)
local f, err = io.open(filename, "w")
if f == nil then
return f, err
end
f:write("Thread: " .. current_thread.subject .. "\n")
f:write("\n")
for _,post in ipairs(current_thread_posts) do
f:write("----------\n")
f:write("At " .. os.date("%H:%M %B %d, %Y", post.timestamp) .. " " .. post.author .. " said:\n")
f:write("----------\n")
f:write("\n")
f:write(file.read(post.filename))
f:write("\n\n")
end
f:close()
return true
end
type_dispatch = {}
type_dispatch["f"] = do_type_first
type_dispatch["l"] = do_type_last
type_dispatch["n"] = do_type_next
type_dispatch["p"] = do_type_prev
type_dispatch["q"] = function() do_quit() os.exit() end
type_dispatch["r"] = do_type_reply
type_dispatch["s"] = do_type_save
type_dispatch["w"] = do_type_whole
type_dispatch["d"] = function() return end
function type_prompt_func()
return "["..colourise("red","f").."]irst, " ..
"["..colourise("red","n").."]ext, " ..
"["..colourise("red","p").."]rev, " ..
"["..colourise("red","l").."]ast, " ..
"["..colourise("red","w").."]hole, " ..
"["..colourise("red","r").."]eply, " ..
"["..colourise("red","s").."]ave, " ..
"["..colourise("red","d").."]one, " ..
"["..colourise("red","q").."]uit > "
end
function do_type()
if not check_at_board() then return end
io.write("Read which thread? ")
local thread_id = tonumber(io.read())
if not thread_id or thread_id < 1 or thread_id > #current_board_threads then
print("Invalid thread index!")
return
end
current_thread = current_board_threads[thread_id]
current_thread_posts = get_posts(current_thread)
if current_thread == nil or #current_thread_posts == 0 then
print("This thread appears corrupted! Aborting.")
return
end
current_post_index = #current_thread_posts
do_type_show_post()
dispatch_loop(type_dispatch, "d", type_prompt_func, "Unrecognised command!")
end
-- end of "type" command implementation
function do_quit()
print("Goodbye!")
end
function do_reply()
if not check_at_board() then return end
io.write("Reply to which thread? ")
local thread_id = tonumber(io.read())
if not thread_id or thread_id < 1 or thread_id > #current_board_threads then
print("Invalid thread index!")
return
end
current_thread = current_board_threads[thread_id]
current_thread_posts = get_posts(current_thread)
current_post_index = #current_thread_posts
do_type_reply()
end
function do_rules()
cat_file(path.join(_BBS_ROOT, "docs", "rules"))
end
function do_finger()
os.execute("/usr/bin/finger -s")
end
-- MAIN PROGRAM BODY BELOW
-- Show welcome message
print("::::::::::: This is telem ver."..telemver.." :::::::::::")
cat_file(path.join(_BBS_ROOT, "docs", "welcome"))
-- Initialise global vars representing BBS state
update_boards()
load_scan_times()
-- Build dispatch table mapping chars to functions
dispatch = {}
dispatch["h"] = do_help
dispatch["H"] = do_hide
dispatch["g"] = do_go
dispatch["l"] = do_list
dispatch["m"] = do_messages
dispatch["q"] = do_quit
dispatch["t"] = do_type
dispatch["?"] = do_help2
dispatch["!"] = do_rules
dispatch["w"] = do_finger
dispatch["s"] = do_scan
dispatch["o"] = do_order
dispatch["n"] = do_new
dispatch["r"] = do_reply
dispatch["R"] = do_recent
dispatch["b"] = do_board
dispatch["c"] = do_colour
dispatch["/"] = do_search
-- Infinite loop of command dispatch
function prompt()
if current_board then
return "["..colourise("cyan", current_board.name).."] COMMAND :> "
else
return "[] COMMAND :> "
end
end
dispatch_loop(dispatch, "q", prompt, "Unrecognised command, hit `h` for help.")