127 lines
3.2 KiB
Python
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)
|