#!/usr/bin/lua5.2 telemver = "0.3 // 2018-05-04" file = require("pl.file") io = require("io") lfs = require("lfs") os = require("os") path = require("pl.path") string = require("string") table = require("table") _BBS_ROOT = "/var/bbs/" -- Global var declarations -- (Not actually required by Lua, just here to make the design clear/explicit) username = os.getenv("USER") -- TODO: This is, obviously, not secure and will need to be updated boards = {} -- Array of board names, alphaetically sorted board_names = {} -- Set of board names, must always be consistent with boards current_board = "" -- String, name of current board, must be in boards and board_names 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 -- 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 -- Internals function update_boards() for board in lfs.dir(path.join(_BBS_ROOT, "boards")) do if string.sub(board, 1, 1) ~= "." and not board_names[board] then board_names[board] = true table.insert(boards, board) end end table.sort(boards) end function get_threads(board) local threads = {} for threaddir in lfs.dir(path.join(_BBS_ROOT, "boards", board)) do if string.sub(threaddir, 1,1) == "." then goto continue end if threaddir == "topic" then goto continue end local thread = {} thread.directory = path.join(_BBS_ROOT, "boards", board, threaddir) _, _, timestamp, thread.author = string.find(threaddir, "(%d+)-(%g+)") thread.timestamp = tonumber(timestamp) 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 for _, post in ipairs(posts) do if post.timestamp > thread.updated then thread.updated = post.timestamp end end table.insert(threads, thread) ::continue:: end table.sort(threads, function(x,y) return x.updated > y.updated 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) _, _, timestamp, post.author = string.find(postfile, "(%d+)-(%a+)") post.timestamp = tonumber(timestamp) table.insert(posts, post) ::continue:: end table.sort(posts, function(x,y) return x.timestamp < y.timestamp end) return posts 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 end io.write("Description? ") local desc = io.read() -- Create directory local board_dir = path.join(_BBS_ROOT, "boards", board) lfs.mkdir(board_dir) -- Write topic file local topic_file = path.join(board_dir, "topic") file.write(topic_file, desc) -- Update representation of BBS board_names[board] = true table.insert(boards, board) table.sort(boards) -- Done! print("Board created.") end function do_go() io.write("Go to which board? ") local board = string.upper(io.read()) if board == "" then do_list() elseif board_names[board] == nil then print("No such board! Hit `l` to list boards.") else current_board = board current_board_threads = get_threads(current_board) end end function do_help() cat_file(path.join(_BBS_ROOT, "docs", "help")) end function do_help2() cat_file(path.join(_BBS_ROOT, "docs", "help2")) end function do_list() update_boards() for _,b in ipairs(boards) do local threads = -3 -- Don't want to count "topic" file or "." or ".." for topic in lfs.dir(path.join(_BBS_ROOT, "boards", b)) do threads = threads +1 end print(string.format("%s [%d threads]", b, threads)) end end function do_messages() if current_board == "" then print("Not at any board! Hit `l` to list boards, `g` to go to one.") else current_board_threads = get_threads(current_board) for i, thread in ipairs(current_board_threads) do print(tostring(i), os.date("%x %H:%M", thread.timestamp), thread.author, thread.subject, "("..tostring(thread.post_count).." posts)") end end end function do_new() if current_board == "" then print("Not at any board!") 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 end -- Save body of post to temp file local filename = os.tmpname() os.execute("$EDITOR " .. filename) -- TODO: show and confirm -- Make thread dir local timestamp = tostring(os.time()) local thread_dir = timestamp .. "-" .. username local thread_path = path.join(_BBS_ROOT, "boards", current_board, thread_dir) lfs.mkdir(thread_path) -- Write subject file file.write(path.join(thread_path, "subject"), 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) local ret, str = file.move(filename, newpath) if not ret then print(str) end -- Done! print("Post submitted.") 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() local filename = os.tmpname() os.execute("$EDITOR " .. filename) local timestamp = tostring(os.time()) local newfilename = timestamp .. "-" .. username local newpath = path.join(current_thread.directory, newfilename) local ret, str = file.move(filename, newpath) if not ret then print(str) end current_thread_posts = get_posts(current_thread) current_post_index = #current_thread_posts do_type_show_post() 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 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["r"] = do_type_reply type_dispatch["d"] = function() return end function do_type() io.write("Read which thread? ") local thread_id = tonumber(io.read()) if not thread_id or thread_id < 0 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_show_post() repeat -- Show prompt and read one char io.write("[f]irst, [n]ext, [p]rev, [l]last, [r]eply, [d]one > ") c = getchar() if c ~= "\n" then io.write("\n") end -- Dispatch to sub-command if c == "\n" then -- pass elseif type_dispatch[c] == nil then print("Invalid command!") elseif c ~= "\n" then type_dispatch[c]() end until c == "d" end -- end of "type" command implementation function do_quit() print("Goodbye!") end function do_reply() if current_board == "" then print("Not at any board!") return end io.write("Reply to which thread? ") local thread_id = tonumber(io.read()) if not thread_id or thread_id < 0 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_unimplemented() print("Sorry, this command is not (yet) implemented!") end function do_who() os.execute("/usr/bin/w") 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() -- Build dispatch table mapping chars to functions dispatch = {} dispatch["h"] = do_help 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_who dispatch["s"] = do_unimplemented dispatch["="] = do_unimplemented dispatch["M"] = do_unimplemented dispatch["n"] = do_new dispatch["r"] = do_reply dispatch["b"] = do_board dispatch["c"] = do_unimplemented -- Infinite loop of command dispatch repeat -- Show prompt and read 1 char from keyboard io.write("["..current_board.."] COMMAND :> ") c = getchar() if c ~= "\n" then io.write("\n") end -- Use char as index into dispatch table if c == "\n" then -- pass elseif dispatch[c] == nil then print("Unrecognised command, hit `h` for help.") else dispatch[c]() end until c == "q"