First git commit

This commit is contained in:
root
2026-09-08 18:21:10 +01:00
commit 66911dc450
65 changed files with 7012 additions and 0 deletions

241
bbs/bbsd.py Normal file
View File

@ -0,0 +1,241 @@
#!/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())