Files
2026-09-08 18:21:10 +01:00

127 lines
3.2 KiB
Python

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