242 lines
7.6 KiB
Python
242 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""txt3 BBS - telnet front-end for wap.txt3.net.
|
|
|
|
Shares the SQLite database with the WML/XHTML site: one account works on all
|
|
three front-ends, and multiplayer matches are cross-playable between them.
|
|
|
|
Binds to the tailscale interface only by default.
|
|
"""
|
|
import os
|
|
import socket
|
|
import socketserver
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
import bbsdb # noqa: E402
|
|
from bbsdb import connect, q1, login, create_user, user_by_name, touch # noqa: E402
|
|
from term import Term, Hangup # noqa: E402
|
|
import boards # noqa: E402
|
|
import games # noqa: E402
|
|
import mud # noqa: E402
|
|
import screens # noqa: E402
|
|
|
|
HOST = os.environ.get("BBS_HOST", "100.127.96.105")
|
|
PORT = int(os.environ.get("BBS_PORT", "12300"))
|
|
MAX_SESSIONS = int(os.environ.get("BBS_MAX", "20"))
|
|
|
|
_sessions = threading.BoundedSemaphore(MAX_SESSIONS)
|
|
|
|
BANNER = r"""
|
|
_ _ _____ ____ ____ ____
|
|
| |___ _| |_|___ / | __ )| __ ) ___|
|
|
| __\ \/ / __| |_ \ | _ \| _ \___ \
|
|
| |_ > <| |_ ___) | | |_) | |_) |__) |
|
|
\__/_/\_\\__|____/ |____/|____/____/
|
|
"""
|
|
|
|
|
|
def log(msg):
|
|
sys.stdout.write("[%s] %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg))
|
|
sys.stdout.flush()
|
|
|
|
|
|
def welcome(t, con):
|
|
t.write(BANNER)
|
|
motd = q1(con, "SELECT v FROM settings WHERE k='motd'")
|
|
t.line(" " + (motd["v"] if motd else "Welcome!"))
|
|
users = q1(con, "SELECT COUNT(*) c FROM users")["c"]
|
|
posts = q1(con, "SELECT COUNT(*) c FROM posts")["c"]
|
|
t.line(" %d members, %d posts. Also on the web: https://wap.txt3.net" % (users, posts))
|
|
t.rule("=")
|
|
|
|
|
|
def do_login(t, con):
|
|
"""Returns a user row, or None if the caller gave up."""
|
|
for _ in range(3):
|
|
t.line("\n [L]ogin [N]ew user [G]uest look around [Q]uit")
|
|
c = t.ask("\n> ").lower()
|
|
if c.startswith("l"):
|
|
name = t.ask(" Username: ", maxlen=16)
|
|
if not name:
|
|
continue
|
|
pw = t.secret(" Password: ")
|
|
u = login(con, name, pw)
|
|
if u:
|
|
t.line("\n Welcome back, %s." % u["username"])
|
|
return u
|
|
t.line(" Login failed (bad details, or account banned).")
|
|
elif c.startswith("n"):
|
|
u = do_signup(t, con)
|
|
if u:
|
|
return u
|
|
elif c.startswith("g"):
|
|
return "guest"
|
|
elif c.startswith("q"):
|
|
return None
|
|
return None
|
|
|
|
|
|
def do_signup(t, con):
|
|
t.line("\n New account:")
|
|
name = t.ask(" Choose a username (3-16, letters/digits/_): ", maxlen=16)
|
|
if not name:
|
|
return None
|
|
if not name.replace("_", "").isalnum() or not 3 <= len(name) <= 16:
|
|
t.line(" Invalid username.")
|
|
return None
|
|
if user_by_name(con, name):
|
|
t.line(" That name is taken.")
|
|
return None
|
|
pw = t.secret(" Choose a password (min 4): ")
|
|
if len(pw) < 4:
|
|
t.line(" Too short.")
|
|
return None
|
|
if pw != t.secret(" Repeat password: "):
|
|
t.line(" Passwords did not match.")
|
|
return None
|
|
uid = create_user(con, name, pw)
|
|
if not uid:
|
|
t.line(" Could not create the account (hashing failed).")
|
|
return None
|
|
t.line("\n Account created. It works on https://wap.txt3.net too.")
|
|
return bbsdb.user_by_id(con, uid)
|
|
|
|
|
|
def guest_menu(t, con):
|
|
while True:
|
|
t.header("GUEST - read only")
|
|
t.line(" [F]orums (read) [U]ser list [S]cores [L]ogin/signup [Q]uit")
|
|
c = t.ask("\nguest> ").lower()
|
|
if c.startswith("f"):
|
|
boards.forum_menu(t, con, {"id": -1, "is_admin": 0, "username": "guest"})
|
|
elif c.startswith("u"):
|
|
screens.userlist(t, con)
|
|
elif c.startswith("s"):
|
|
games.scores(t, con)
|
|
elif c.startswith("l"):
|
|
return "login"
|
|
elif c.startswith("q") or c == "":
|
|
return None
|
|
|
|
|
|
def main_menu(t, con, me):
|
|
while True:
|
|
me = bbsdb.user_by_id(con, me["id"])
|
|
if not me or me["is_banned"]:
|
|
t.line("\n Your account is no longer active. Goodbye.")
|
|
return
|
|
touch(con, me["id"])
|
|
n = bbsdb.unread(con, me["id"])
|
|
t.header("MAIN MENU - %s%s" % (me["username"],
|
|
" [admin]" if me["is_admin"] else ""))
|
|
t.line(" [M]ail %s" % ("(%d unread)" % n if n else ""))
|
|
t.line(" [F]orums")
|
|
t.line(" [G]ames")
|
|
t.line(" [D]ungeon - explore a shared world (also on the web)")
|
|
t.line(" [P]rofile")
|
|
t.line(" [U]ser list")
|
|
if me["is_admin"]:
|
|
t.line(" [A]dmin tools")
|
|
t.line(" [Q]uit")
|
|
c = t.ask("\nmain> ").lower()
|
|
if c.startswith("m"):
|
|
boards.mail_menu(t, con, me)
|
|
elif c.startswith("f"):
|
|
boards.forum_menu(t, con, me)
|
|
elif c.startswith("g"):
|
|
games.games_menu(t, con, me)
|
|
elif c.startswith("d"):
|
|
mud.mud_menu(t, con, me)
|
|
elif c.startswith("p"):
|
|
screens.profile_menu(t, con, me)
|
|
elif c.startswith("u"):
|
|
screens.userlist(t, con)
|
|
elif c.startswith("a") and me["is_admin"]:
|
|
screens.admin_menu(t, con, me)
|
|
elif c.startswith("q"):
|
|
return
|
|
|
|
|
|
class Handler(socketserver.BaseRequestHandler):
|
|
def handle(self):
|
|
if not _sessions.acquire(blocking=False):
|
|
try:
|
|
self.request.sendall(b"\r\nBBS is full, try later.\r\n")
|
|
self.request.close()
|
|
except OSError:
|
|
pass
|
|
return
|
|
con = None
|
|
t = None
|
|
ip = self.client_address[0]
|
|
log("connect from %s" % ip)
|
|
try:
|
|
t = Term(self.request, self.client_address)
|
|
con = connect()
|
|
welcome(t, con)
|
|
while True:
|
|
who = do_login(t, con)
|
|
if who is None:
|
|
break
|
|
if who == "guest":
|
|
if guest_menu(t, con) == "login":
|
|
continue
|
|
break
|
|
main_menu(t, con, who)
|
|
break
|
|
if t:
|
|
t.line("\n Goodbye - 73s from txt3 BBS.\n")
|
|
except Hangup:
|
|
log("hangup %s" % ip)
|
|
except Exception as e: # keep one session's bug local
|
|
log("ERROR %s: %r" % (ip, e))
|
|
try:
|
|
t.line("\n Internal error - disconnecting.\n")
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
if con is not None:
|
|
try:
|
|
con.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self.request.close()
|
|
except OSError:
|
|
pass
|
|
_sessions.release()
|
|
log("disconnect %s" % ip)
|
|
|
|
|
|
class Server(socketserver.ThreadingTCPServer):
|
|
allow_reuse_address = True
|
|
daemon_threads = True
|
|
address_family = socket.AF_INET
|
|
|
|
|
|
def main():
|
|
# Fail loudly if the shared database is not reachable.
|
|
try:
|
|
con = connect()
|
|
con.execute("SELECT 1 FROM users LIMIT 1")
|
|
con.close()
|
|
except Exception as e:
|
|
log("FATAL: cannot open database %s: %r" % (bbsdb.DB_PATH, e))
|
|
return 1
|
|
srv = Server((HOST, PORT), Handler)
|
|
log("txt3 BBS listening on %s:%d (db=%s)" % (HOST, PORT, bbsdb.DB_PATH))
|
|
try:
|
|
srv.serve_forever()
|
|
except KeyboardInterrupt:
|
|
log("shutting down")
|
|
finally:
|
|
srv.server_close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|