aboutsummaryrefslogblamecommitdiff
path: root/telem.lua
blob: f35afe227a6e1a4324d2dbedcbbb8ee0138df2a3 (plain) (tree)
1
2
3
4
5
6
7
8
9
                  
                              
 
                    
                         
                  
                    

                         
                          
                               
                        
                                
 
                       
                            
                   
                                     
   






                                     







                   
 
                          
 
                 


                                                                                                               



                                                                                                           
                                                                                
                                                                       
                                                                                
 














                                    

                    
                           
                                     


                  
                                       
                               



                                      




                                                                                                             
















                                                                       


                        
                                                                  
                                                                                          



                                                                                    
                                              
                                                          
                                                   

                   
                                                                    

   
                         
                                    

                                                                                     
                                   

   

                           
                                                    


                                                                           
                                                                        
                                                                                     
                                                      
                                      







                                                                
                                      


                                                               
                                                               
                           
                   







                                                                    
                            
           




                                                                                   




                          


                                                                          
                               
                                                                     
                                                                                    
                                                    
                                    

                                         




                                                                             

                                                                          
                     
                                             
                                   
                                
                                                                              




                                                                               
                    



                                                                          
                     




                                                                                  
                    

   

           



                                      
                                                   
                                             


                                                                   


                                                                           


                                             
                                                

                                                              
           
                                               
                              



                                                                    
                           
                     
                                                               
                            
                                                
                           
                                                        
                                    
                                               
                    
                                       
                       



                               









                                                                 
                   








                                                             
                  








                                             
                
                                                           
                                             

                           


                                                                  
                                          
                                                                
            
                                             
                                                    

                                                                        

                                                                          
                   



                  







                                                                    











                                                                    

                                                             
                                                        

                                                   


                   
                                                       


                  
                       
                                                                                      
                                                
                                                                  


                                        
                                                                                     
                                                         
                                            
                   
                                                                 
                                                                        

                                                               
           
                                 


                      

                                                          



                                           
                  

                                                                                                                   


                                 
                                                         



                                                                                                     
                                                                                                   
                                                                             
                                                                        
                                           
           

                                 


                                              

   



                                                       
                


                                                        



                                                  














                                                           
                                     

                          


                                                                      

                               

   
                 
                                               
                                     
                             
                                 


                                                                       


                                              
           
                                         




                                        

                                 

                                                       
                                                                          
                     
                              
                                                  
                             

                                                                      
                         

                                                                                           

                                                                   


                          
                                            


                                           



                                

































                                                                                                                
                  

                                                  
                                                                                        


                                                   
                                                                                                     





                                                                     
                                                                                                     



                   


                                                                      

                        
                              
                           

   
                       
                                                  


                           
                       

                                                           
           
                           


                       

                                                           
           
                           


                        


                                         
                                         


                                                                 

                                                        
                                                                        

                                                     


                          
                                            

                             
                                      

                                                        
                           

   


                                         



                                          










                                                   










                                                                                                          








                                                             
                                                                                                                








                                                 

                                  
                                 

                                 
                                                       
                                  

                                  

                                          







                                                       

                                                      

   
                  
                                               
                                       
                                             
                                                                                    


                                              

                                                         



                                                                   
                                                  
                           
                                                                                    

   
                                       




                         
                   
                                               
                                           
                                             
                                                                                    


                                              
                                                         
                                                        
                                                  


                       
                   
                                                       

   
                 
                                

   

                          


                                                                 
 

                                                
                 



                                                  
                       






                           
                      
                       
                        
                      
                        
                         
                        
                         

                                    

                             
                                                                                  





                                                                               
#!/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

_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+rwx " .. 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

	[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
	(q)uit ......... Immediately quit the board
	]])
end

function do_help2()
	cat_file(path.join(_BBS_ROOT, "docs", "help2"))
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("Author",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+rwx " .. 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 = {}
	local N = 10
	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 #recent < N then
				table.insert(recent, x)
				table.sort(recent, function(x,y) return x.thread.updated > y.thread.updated end)
			elseif thread.updated > recent[N].thread.updated then
				table.remove(recent, N)
				table.insert(recent, x)
				table.sort(recent, function(x,y) return x.thread.updated > y.thread.updated end)
			end
		end
	end
	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 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

-- 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("less " .. 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_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()
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_who
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

-- 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.")