"""Shared data layer for the txt3 BBS (telnet front-end). Talks to the exact same SQLite database as the WAP/XHTML site, so accounts, mail, forums and scores are identical across all three front-ends. Password hashes are PHP `password_hash()` bcrypt ($2y$) values. Rather than depend on a bcrypt wheel, we verify/create them by calling the php binary, passing values through the environment so nothing is ever interpolated into a shell string. """ import os import json import sqlite3 import subprocess import time DB_PATH = os.environ.get( "BBS_DB", "/home/txt3/domains/wap.txt3.net/public_html/data/wap.sqlite" ) PHP_BIN = os.environ.get("BBS_PHP", "/usr/bin/php") def connect(): con = sqlite3.connect(DB_PATH, timeout=10) con.row_factory = sqlite3.Row con.execute("PRAGMA journal_mode = WAL") con.execute("PRAGMA foreign_keys = ON") con.execute("PRAGMA busy_timeout = 5000") return con def q(con, sql, args=()): return con.execute(sql, args).fetchall() def q1(con, sql, args=()): r = con.execute(sql, args).fetchone() return r def ex(con, sql, args=()): cur = con.execute(sql, args) con.commit() return cur.lastrowid # ---------------------------------------------------------------- passwords def _php(code, env): e = dict(os.environ) e.update(env) try: out = subprocess.run( [PHP_BIN, "-r", code], capture_output=True, text=True, timeout=10, env=e, ) return out.stdout.strip() except Exception: return "" def verify_password(plain, hashed): return _php( 'echo password_verify(getenv("BP_P"), getenv("BP_H")) ? "1" : "0";', {"BP_P": plain, "BP_H": hashed}, ) == "1" def hash_password(plain): h = _php( 'echo password_hash(getenv("BP_P"), PASSWORD_DEFAULT);', {"BP_P": plain}, ) return h or None # ---------------------------------------------------------------- users def user_by_name(con, name): return q1(con, "SELECT * FROM users WHERE username=? COLLATE NOCASE", (name,)) def user_by_id(con, uid): return q1(con, "SELECT * FROM users WHERE id=?", (uid,)) def login(con, name, password): u = user_by_name(con, name) if not u or u["is_banned"]: return None if not verify_password(password, u["pass_hash"]): return None ex(con, "UPDATE users SET last_seen=? WHERE id=?", (int(time.time()), u["id"])) return u def create_user(con, name, password): h = hash_password(password) if not h: return None now = int(time.time()) uid = ex( con, "INSERT INTO users (username,pass_hash,is_admin,created_at,last_seen)" " VALUES (?,?,0,?,?)", (name, h, now, now), ) return uid def touch(con, uid): ex(con, "UPDATE users SET last_seen=? WHERE id=?", (int(time.time()), uid)) def unread(con, uid): return q1(con, "SELECT COUNT(*) c FROM messages WHERE to_id=? AND is_read=0", (uid,))["c"] def ago(ts): d = max(0, int(time.time()) - int(ts or 0)) if d < 60: return "%ds" % d if d < 3600: return "%dm" % (d // 60) if d < 86400: return "%dh" % (d // 3600) return "%dd" % (d // 86400)