#!/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.")