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