Files
AWCharacterCreator/backend/app/main.py
T

1223 lines
58 KiB
Python

import json
import os
import re
import secrets
import sqlite3
import time
import uuid
from pathlib import Path
import asyncio
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from . import auth
from .derive import compute
from . import rules_edit
from . import maps as maps_mod
from . import sounds as sounds_mod
from . import portrait as portrait_mod
from . import combat as combat_mod
from .realtime import hub
from .export import to_docx, to_pdf
BASE = Path(__file__).resolve().parent.parent
RULES_PATH = Path(os.environ.get("AW_RULES", BASE / "data" / "rules.json"))
DB_PATH = os.environ.get("AW_DB", str(BASE / "aw.db"))
STATIC = Path(os.environ.get("AW_STATIC", BASE / "static"))
MAX_BODY = 200_000 # bytes per character
MAX_PRESETS = 200
MAX_CHARACTERS = 300 # per user
MAX_SHARES = 30 # per character
MAX_LOBBIES = 20 # per DM
MAX_MEMBERS = 20 # per lobby
MAX_MESSAGE = 500 # characters per chat message
KEEP_MESSAGES = 1000 # per lobby, older ones are dropped
PALETTE = [ # player colours (dice, cards, chat names); every player in a lobby has a different one
("red", "#e0524d"), ("orange", "#ea8a3b"), ("yellow", "#e6c52e"), ("green", "#6fc24b"), ("teal", "#2fb8a6"),
("blue", "#4f93e6"), ("purple", "#9d6fda"), ("pink", "#e870b1"), ("white", "#e9e6da"), ("brown", "#a8764f")]
PALETTE_HEX = dict(PALETTE)
DM_COLOR = "#d4af37"
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no 0/O/1/I
# No public API docs: everything about the API stays behind the login
app = FastAPI(title="Arcane Wasteland Character Creator", docs_url=None, redoc_url=None, openapi_url=None)
limiter = auth.Limiter()
@app.on_event("startup")
async def _attach_hub():
hub.attach(asyncio.get_running_loop())
TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse proxy that sets X-Forwarded-For / -Proto
# Changes made in the rules editor live next to the database (writable), the shipped rules.json stays untouched.
OVERRIDES_PATH = Path(DB_PATH).parent / "rules_overrides.json"
def base_rules() -> dict:
return json.loads(RULES_PATH.read_text(encoding="utf-8"))
def read_overrides() -> dict:
try:
return json.loads(OVERRIDES_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def load_rules() -> dict:
# Re-read on every request so rules.json can be edited without a restart; editor changes replace whole sections.
r = base_rules()
for sec, items in read_overrides().items():
if sec in rules_edit.SECTIONS:
r[sec] = items
return r
def db() -> sqlite3.Connection:
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
con.execute("""CREATE TABLE IF NOT EXISTS characters (
id TEXT PRIMARY KEY, token TEXT NOT NULL, data TEXT NOT NULL,
created REAL NOT NULL, updated REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS presets (
id TEXT PRIMARY KEY, token TEXT NOT NULL, data TEXT NOT NULL,
created REAL NOT NULL, updated REAL NOT NULL)""")
auth.init_tables(con)
con.execute("""CREATE TABLE IF NOT EXISTS shares (
character_id TEXT NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (character_id, user_id))""")
con.execute("""CREATE TABLE IF NOT EXISTS lobbies (
id TEXT PRIMARY KEY, name TEXT NOT NULL, owner_id INTEGER NOT NULL, code TEXT NOT NULL UNIQUE, created REAL NOT NULL)""")
con.execute("""CREATE TABLE IF NOT EXISTS lobby_members (
lobby_id TEXT NOT NULL, user_id INTEGER NOT NULL, character_id TEXT, joined REAL NOT NULL,
PRIMARY KEY (lobby_id, user_id))""")
con.execute("""CREATE TABLE IF NOT EXISTS lobby_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, lobby_id TEXT NOT NULL, user_id INTEGER, to_user_id INTEGER,
kind TEXT NOT NULL, text TEXT NOT NULL, created REAL NOT NULL)""")
con.execute("CREATE INDEX IF NOT EXISTS lobby_messages_lobby ON lobby_messages (lobby_id, id)")
for table, col, ddl in (("lobbies", "diff_mod", "INTEGER NOT NULL DEFAULT 0"), ("lobbies", "diff_label", "TEXT NOT NULL DEFAULT 'Challenging'"),
("lobbies", "diff_hidden", "INTEGER NOT NULL DEFAULT 0"), ("lobby_members", "color", "TEXT"),
("lobby_members", "char_mod", "INTEGER NOT NULL DEFAULT 0")):
if col not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ddl}")
if "is_admin" not in [r["name"] for r in con.execute("PRAGMA table_info(users)")]:
con.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
if "npc" not in [r["name"] for r in con.execute("PRAGMA table_info(characters)")]:
con.execute("ALTER TABLE characters ADD COLUMN npc INTEGER NOT NULL DEFAULT 0")
maps_mod.schema(con)
sounds_mod.schema(con)
combat_mod.schema(con)
for table in ("characters", "presets"): # older databases: add the owner column
if "owner_id" not in [r["name"] for r in con.execute(f"PRAGMA table_info({table})")]:
con.execute(f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER")
return con
def custom_presets(con, uid=None) -> list:
rows = con.execute("""SELECT p.id, p.data, p.owner_id, u.username FROM presets p
LEFT JOIN users u ON u.id = p.owner_id ORDER BY p.created""").fetchall()
return [{"id": r["id"], **json.loads(r["data"]), "custom": True, "author": r["username"] or "",
"mine": uid is not None and r["owner_id"] == uid} for r in rows]
def all_rules(uid=None) -> dict:
"""Rules from rules.json plus the presets created in the app."""
r = load_rules()
with db() as con:
custom = custom_presets(con, uid)
r["presets"] = [{**p, "builtin": True} for p in r.get("presets", [])] + custom
return r
def clean_preset(body: dict, rules: dict) -> dict:
"""Validates a preset: name, description and only known creation numbers within their limits."""
name = str(body.get("name", "")).strip()[:60]
if not name:
raise HTTPException(422, "A preset needs a name")
tun = {t["key"]: t for t in rules.get("tunables", [])}
creation = {}
for k, v in (body.get("creation") or {}).items():
if k not in tun:
raise HTTPException(422, f"Unknown value: {k}")
if isinstance(v, bool) or not isinstance(v, int) or not tun[k]["min"] <= v <= tun[k]["max"]:
raise HTTPException(422, f"Value out of range: {tun[k]['label']}")
if v != rules["creation"].get(k):
creation[k] = v
return {"name": name, "desc": str(body.get("desc", "")).strip()[:300], "creation": creation}
def check_size(ch: dict):
if len(json.dumps(ch)) > MAX_BODY:
raise HTTPException(413, "Character too large")
# ---------------------------------------------------------------- security
def client_ip(request: Request) -> str:
if TRUST_PROXY:
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[-1].strip() # the address the reverse proxy saw
return request.client.host if request.client else "?"
def is_https(request: Request) -> bool:
if TRUST_PROXY:
return request.headers.get("x-forwarded-proto", "") == "https"
return request.url.scheme == "https"
@app.middleware("http")
async def security(request: Request, call_next):
# Block cross-site writes (cookies are SameSite=Lax as well)
if request.method not in ("GET", "HEAD", "OPTIONS"):
origin = request.headers.get("origin")
hosts = {request.headers.get("host", "")}
if TRUST_PROXY:
hosts.add(request.headers.get("x-forwarded-host", ""))
if origin and origin.split("://", 1)[-1] not in hosts:
return JSONResponse({"detail": "Cross-site request refused"}, status_code=403)
resp = await call_next(request)
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "same-origin"
host = request.headers.get("x-forwarded-host", "") if TRUST_PROXY else ""
host = re.sub(r"[^A-Za-z0-9.:\-\[\]]", "", host or request.headers.get("host", ""))
resp.headers["Content-Security-Policy"] = ("default-src 'self'; style-src 'self' 'unsafe-inline'; "
f"img-src 'self' data: blob:; connect-src 'self' ws://{host} wss://{host}; frame-ancestors 'none'")
if request.url.path.startswith("/api/"):
resp.headers["Cache-Control"] = "no-store"
return resp
def admin_ids(con) -> set:
"""Admins: the names in AW_ADMINS, accounts an admin promoted, and (when there is neither) the very first account."""
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
ids = {r["id"] for r in con.execute("SELECT id, username FROM users") if r["username"].lower() in names}
ids |= {r["id"] for r in con.execute("SELECT id FROM users WHERE is_admin=1")}
if not ids:
first = con.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
if first:
ids.add(first["id"])
return ids
def is_admin(user: dict) -> bool:
with db() as con:
return user["id"] in admin_ids(con)
def current_user(request: Request) -> dict:
with db() as con:
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
if not user:
raise HTTPException(401, "Not logged in")
return user
def set_cookie(request: Request, resp: Response, token: str):
resp.set_cookie(auth.SESSION_COOKIE, token, max_age=auth.SESSION_DAYS * 86400, httponly=True,
samesite="lax", secure=is_https(request), path="/")
def throttle(key: str, limit: int, window: int):
wait = limiter.blocked(key, limit, window)
if wait:
raise HTTPException(429, f"Too many attempts. Try again in {wait // 60 + 1} minute(s).",
headers={"Retry-After": str(wait)})
def check_password_rules(pw: str):
if not isinstance(pw, str) or len(pw) < auth.MIN_PASSWORD:
raise HTTPException(422, f"The password needs at least {auth.MIN_PASSWORD} characters")
if len(pw) > auth.MAX_PASSWORD:
raise HTTPException(422, "The password is too long")
# ---------------------------------------------------------------- auth routes (public)
@app.get("/api/auth/me")
def me(request: Request):
with db() as con:
user = auth.user_for_token(con, request.cookies.get(auth.SESSION_COOKIE))
if user:
user = {**user, "admin": is_admin(user)}
return {"user": user, "registration_open": bool(auth.registration_code())}
@app.post("/api/auth/register")
def register(request: Request, response: Response, body: dict = Body(...)):
code_cfg = auth.registration_code()
if not code_cfg:
raise HTTPException(403, "Registration is disabled on this server")
ip = client_ip(request)
# The shared code may be weak, so wrong guesses are throttled per address and for the whole server.
throttle(f"reg:{ip}", 5, 900)
throttle("reg:all", 30, 3600)
username, password, code = str(body.get("username", "")).strip(), body.get("password", ""), str(body.get("code", ""))
if not secrets.compare_digest(code.encode(), code_cfg.encode()):
limiter.fail(f"reg:{ip}"); limiter.fail("reg:all")
raise HTTPException(403, "Wrong registration code")
if not auth.USERNAME_RE.match(username):
raise HTTPException(422, "Username: 3-32 characters, letters, digits and . _ - only")
check_password_rules(password)
with db() as con:
if con.execute("SELECT 1 FROM users WHERE username=?", (username,)).fetchone():
raise HTTPException(409, "This username is taken")
cur = con.execute("INSERT INTO users (username, pw_hash, created) VALUES (?,?,?)",
(username, auth.hash_password(password), time.time()))
token = auth.create_session(con, cur.lastrowid)
set_cookie(request, response, token)
u = {"id": cur.lastrowid, "username": username}
return {"user": {**u, "admin": is_admin(u)}}
@app.post("/api/auth/login")
def login(request: Request, response: Response, body: dict = Body(...)):
username, password = str(body.get("username", "")).strip(), str(body.get("password", ""))[:auth.MAX_PASSWORD]
ip = client_ip(request)
keys = (f"login:{ip}", f"login:u:{username.lower()}")
throttle(keys[0], 10, 600)
throttle(keys[1], 8, 600)
with db() as con:
row = con.execute("SELECT id, username, pw_hash FROM users WHERE username=?", (username,)).fetchone()
ok = bool(row) and auth.verify_password(password, row["pw_hash"])
if not row:
auth.timing_dummy(password)
if not ok:
for k in keys:
limiter.fail(k)
raise HTTPException(401, "Wrong username or password")
token = auth.create_session(con, row["id"])
limiter.reset(keys[1])
set_cookie(request, response, token)
u = {"id": row["id"], "username": row["username"]}
return {"user": {**u, "admin": is_admin(u)}}
@app.post("/api/auth/logout")
def logout(request: Request, response: Response):
with db() as con:
auth.end_session(con, request.cookies.get(auth.SESSION_COOKIE))
response.delete_cookie(auth.SESSION_COOKIE, path="/")
return {"ok": True}
# ---------------------------------------------------------------- everything below needs a login
api = APIRouter(prefix="/api", dependencies=[Depends(current_user)])
@api.post("/auth/password")
def change_password(request: Request, response: Response, body: dict = Body(...), user: dict = Depends(current_user)):
throttle(f"pw:{user['id']}", 8, 600)
old, new = str(body.get("old", "")), body.get("new", "")
check_password_rules(new)
with db() as con:
row = con.execute("SELECT pw_hash FROM users WHERE id=?", (user["id"],)).fetchone()
if not auth.verify_password(old, row["pw_hash"]):
limiter.fail(f"pw:{user['id']}")
raise HTTPException(403, "The current password is wrong")
con.execute("UPDATE users SET pw_hash=? WHERE id=?", (auth.hash_password(new), user["id"]))
con.execute("DELETE FROM sessions WHERE user_id=?", (user["id"],)) # log out all other devices
token = auth.create_session(con, user["id"])
set_cookie(request, response, token)
return {"ok": True}
@api.get("/rules")
def get_rules(user: dict = Depends(current_user)):
return all_rules(user["id"])
# ---- presets
def _owned(con, table: str, rid: str, user: dict):
row = con.execute(f"SELECT owner_id FROM {table} WHERE id=?", (rid,)).fetchone()
if not row:
raise HTTPException(404, "Not found")
if row["owner_id"] != user["id"]:
raise HTTPException(403, "This belongs to another user")
@api.post("/presets")
def create_preset(body: dict = Body(...), user: dict = Depends(current_user)):
data = clean_preset(body, load_rules())
with db() as con:
if con.execute("SELECT COUNT(*) FROM presets").fetchone()[0] >= MAX_PRESETS:
raise HTTPException(409, "Too many presets on this server")
pid, now = "u-" + uuid.uuid4().hex[:8], time.time()
con.execute("INSERT INTO presets (id, token, data, created, updated, owner_id) VALUES (?,?,?,?,?,?)",
(pid, "", json.dumps(data), now, now, user["id"]))
return {"id": pid}
@api.put("/presets/{pid}")
def update_preset(pid: str, body: dict = Body(...), user: dict = Depends(current_user)):
data = clean_preset(body, load_rules())
with db() as con:
_owned(con, "presets", pid, user)
con.execute("UPDATE presets SET data=?, updated=? WHERE id=?", (json.dumps(data), time.time(), pid))
return {"ok": True}
@api.delete("/presets/{pid}")
def delete_preset(pid: str, user: dict = Depends(current_user)):
with db() as con:
_owned(con, "presets", pid, user)
con.execute("DELETE FROM presets WHERE id=?", (pid,))
return {"ok": True}
# ---- characters (any logged-in user can open a shared link, only the owner can change or delete)
def _name(data: str) -> str:
return json.loads(data).get("name") or "Nameless"
def _entry(r, shares=None, owner=None) -> dict:
ch = json.loads(r["data"])
e = {"id": r["id"], "name": ch.get("name") or "Nameless", "updated": r["updated"], "portrait": portrait_mod.tag(ch.get("portrait"))}
if r["npc"]:
e["kind"] = str(ch.get("npc_kind") or "")
e["size"] = ch.get("token_size", 1)
if shares is not None:
e["shared_with"] = shares.get(r["id"], [])
if owner is not None:
e["owner"] = owner or "?"
return e
@api.get("/characters")
def my_characters(npc: int = 0, user: dict = Depends(current_user)):
"""Your characters and the ones shared with you. With ?npc=1 the NPC library (NPCs, enemies, creatures) instead."""
flag = 1 if npc else 0
with db() as con:
rows = con.execute("SELECT id, data, updated, npc FROM characters WHERE owner_id=? AND npc=? ORDER BY updated DESC",
(user["id"], flag)).fetchall()
shares: dict = {}
for r in con.execute("""SELECT s.character_id, u.username FROM shares s JOIN users u ON u.id = s.user_id
JOIN characters c ON c.id = s.character_id WHERE c.owner_id=? AND c.npc=? ORDER BY u.username""",
(user["id"], flag)):
shares.setdefault(r["character_id"], []).append(r["username"])
shared = con.execute("""SELECT c.id, c.data, c.updated, c.npc, o.username AS owner FROM shares s
JOIN characters c ON c.id = s.character_id LEFT JOIN users o ON o.id = c.owner_id
WHERE s.user_id=? AND c.npc=? ORDER BY c.updated DESC""", (user["id"], flag)).fetchall()
return {"mine": [_entry(r, shares=shares) for r in rows], "shared": [_entry(r, owner=r["owner"]) for r in shared]}
@api.post("/characters")
def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
portrait_mod.clean(ch)
check_size(ch)
with db() as con:
if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS:
raise HTTPException(409, "Character limit reached")
cid, now = uuid.uuid4().hex[:12], time.time()
con.execute("INSERT INTO characters (id, token, data, created, updated, owner_id, npc) VALUES (?,?,?,?,?,?,?)",
(cid, "", json.dumps(ch), now, now, user["id"], 1 if ch.get("npc") else 0))
return {"id": cid}
def _can_read_character(con, cid: str, user: dict) -> bool:
"""The owner, users the character is shared with, the DM of a lobby it plays in, and the DM of a lobby with a token of this NPC."""
row = con.execute("SELECT owner_id FROM characters WHERE id=?", (cid,)).fetchone()
return bool(row) and bool(row["owner_id"] == user["id"] or con.execute(
"SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone() or con.execute(
"""SELECT 1 FROM lobby_members m JOIN lobbies l ON l.id = m.lobby_id
WHERE m.character_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone() or con.execute(
"""SELECT 1 FROM lobby_tokens t JOIN lobbies l ON l.id = t.lobby_id
WHERE t.npc_id=? AND l.owner_id=?""", (cid, user["id"])).fetchone())
@api.get("/characters/{cid}/portrait")
def character_portrait(cid: str, user: dict = Depends(current_user)):
with db() as con:
row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()
allowed = _can_read_character(con, cid, user)
img = portrait_mod.image(json.loads(row["data"]).get("portrait")) if row and allowed else None
if not img:
raise HTTPException(404, "No portrait")
return Response(img, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"})
@api.get("/characters/{cid}")
def read_character(cid: str, user: dict = Depends(current_user)):
"""Readable by the owner, by users the character is shared with, and by the DM of a lobby it plays in."""
with db() as con:
row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone()
allowed = _can_read_character(con, cid, user)
if not allowed:
raise HTTPException(404, "Character not found")
return json.loads(row["data"])
@api.post("/characters/{cid}/shares")
def add_share(cid: str, body: dict = Body(...), user: dict = Depends(current_user)):
name = str(body.get("username", "")).strip()
with db() as con:
_owned(con, "characters", cid, user)
target = con.execute("SELECT id, username FROM users WHERE username=?", (name,)).fetchone()
if not target:
raise HTTPException(404, "No user with this name")
if target["id"] == user["id"]:
raise HTTPException(422, "This is your own character")
if con.execute("SELECT COUNT(*) FROM shares WHERE character_id=?", (cid,)).fetchone()[0] >= MAX_SHARES:
raise HTTPException(409, "Shared with too many users")
con.execute("INSERT OR IGNORE INTO shares VALUES (?,?)", (cid, target["id"]))
return {"username": target["username"]}
@api.delete("/characters/{cid}/shares/{username}")
def remove_share(cid: str, username: str, user: dict = Depends(current_user)):
with db() as con:
_owned(con, "characters", cid, user)
con.execute("DELETE FROM shares WHERE character_id=? AND user_id=(SELECT id FROM users WHERE username=?)",
(cid, username))
return {"ok": True}
@api.put("/characters/{cid}")
def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(current_user)):
portrait_mod.clean(ch)
check_size(ch)
with db() as con:
_owned(con, "characters", cid, user)
con.execute("UPDATE characters SET data=?, updated=?, npc=? WHERE id=?", (json.dumps(ch), time.time(), 1 if ch.get("npc") else 0, cid))
lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))]
for lid in lobbies:
hub.notify(lid, "sheet", cid=cid)
return {"ok": True}
@api.put("/characters/{cid}/notes")
def update_notes(cid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""Only the two note fields, so typing in the lobby never overwrites other changes made elsewhere."""
with db() as con:
_owned(con, "characters", cid, user)
ch = json.loads(con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()["data"])
for k in ("notes", "conditions"):
if k in body:
ch[k] = str(body[k] or "")[:5000]
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
lobbies = [r["lobby_id"] for r in con.execute("SELECT lobby_id FROM lobby_members WHERE character_id=?", (cid,))]
for lid in lobbies:
hub.notify(lid, "sheet", cid=cid)
return {"ok": True}
@api.delete("/characters/{cid}")
def delete_character(cid: str, user: dict = Depends(current_user)):
with db() as con:
_owned(con, "characters", cid, user)
con.execute("DELETE FROM characters WHERE id=?", (cid,))
con.execute("DELETE FROM shares WHERE character_id=?", (cid,))
con.execute("UPDATE lobby_members SET character_id=NULL WHERE character_id=?", (cid,))
return {"ok": True}
# ---- lobbies: the creator is the DM and sees every member's character; players only see a short card of the others
def _lobby_for(con, lid: str, user: dict):
"""Returns the lobby row if the user is its DM or a member, otherwise 404 (hides that it exists)."""
row = con.execute("""SELECT l.*, u.username AS dm FROM lobbies l JOIN users u ON u.id = l.owner_id WHERE l.id=?""",
(lid,)).fetchone()
if not row or (row["owner_id"] != user["id"] and not con.execute(
"SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone()):
raise HTTPException(404, "Lobby not found")
return row
def _dm_only(lobby, user: dict):
if lobby["owner_id"] != user["id"]:
raise HTTPException(403, "Only the DM can do this")
def _new_code(con) -> str:
while True:
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(8))
if not con.execute("SELECT 1 FROM lobbies WHERE code=?", (code,)).fetchone():
return code
def _own_character(con, cid, user: dict):
row = con.execute("SELECT id FROM characters WHERE id=? AND owner_id=? AND npc=0", (cid, user["id"])).fetchone()
if not row:
raise HTTPException(422, "Pick one of your own characters")
def _free_color(con, lid: str):
used = {r["color"] for r in con.execute("SELECT color FROM lobby_members WHERE lobby_id=?", (lid,))}
return next((cid for cid, _ in PALETTE if cid not in used), PALETTE[0][0])
def _ensure_colors(con, lid: str):
"""Members that joined before colours existed get the next free one."""
for r in con.execute("SELECT user_id FROM lobby_members WHERE lobby_id=? AND color IS NULL ORDER BY joined", (lid,)).fetchall():
con.execute("UPDATE lobby_members SET color=? WHERE lobby_id=? AND user_id=?", (_free_color(con, lid), lid, r["user_id"]))
def _levels(rules: dict) -> list:
"""Difficulty levels from the rules text (section 'difficulty', first table): [{name, mod}]."""
out = []
for sec in rules.get("sections", []):
if sec.get("id") != "difficulty":
continue
for block in sec.get("blocks", []):
for row in (block.get("table") or {}).get("rows", []):
try:
out.append({"name": row[0], "mod": int(row[1].replace("\u2212", "-").replace("\u00b1", "").replace("+", "").strip() or 0)})
except (ValueError, IndexError):
pass
return out
def _event(con, lid: str, text: str, uid=None):
"""Writes a line into the lobby's event log (shown in the chat feed to everybody)."""
con.execute("INSERT INTO lobby_messages (lobby_id, user_id, kind, text, created) VALUES (?,?, 'event', ?, ?)",
(lid, uid, text, time.time()))
hub.notify(lid, "messages")
def _char_name(con, cid) -> str:
row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()
return _name(row["data"]) if row else "a character"
def _card(data: str, rules: dict) -> dict:
"""The superficial look at a character that fellow players get: name, concept and class/profession names only."""
ch = json.loads(data)
cls = next((c for c in rules["classes"] if c["id"] == ch.get("class_id")), None)
sub = next((x for x in (cls or {}).get("subclasses", []) if x["id"] == ch.get("subclass_id")), None)
prof = next((p for p in rules["professions"] if p["id"] == ch.get("profession")), None)
return {"name": ch.get("name") or "Nameless", "concept": str(ch.get("concept") or "")[:120],
"class": (cls or {}).get("name", ""), "subclass": (sub or {}).get("name", ""),
"profession": (prof or {}).get("name", ""), "portrait": portrait_mod.tag(ch.get("portrait"))}
@api.get("/lobbies")
def my_lobbies(user: dict = Depends(current_user)):
with db() as con:
mine = con.execute("""SELECT l.id, l.name, (SELECT COUNT(*) FROM lobby_members m WHERE m.lobby_id = l.id) AS n
FROM lobbies l WHERE l.owner_id=? ORDER BY l.created DESC""", (user["id"],)).fetchall()
joined = con.execute("""SELECT l.id, l.name, u.username AS dm, c.data FROM lobby_members m
JOIN lobbies l ON l.id = m.lobby_id JOIN users u ON u.id = l.owner_id
LEFT JOIN characters c ON c.id = m.character_id
WHERE m.user_id=? ORDER BY m.joined DESC""", (user["id"],)).fetchall()
return {"mine": [{"id": r["id"], "name": r["name"], "members": r["n"]} for r in mine],
"joined": [{"id": r["id"], "name": r["name"], "dm": r["dm"], "character": _name(r["data"]) if r["data"] else ""}
for r in joined]}
@api.post("/lobbies")
def create_lobby(body: dict = Body(...), user: dict = Depends(current_user)):
name = str(body.get("name", "")).strip()[:60]
if not name:
raise HTTPException(422, "Give the lobby a name")
with db() as con:
if con.execute("SELECT COUNT(*) FROM lobbies WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_LOBBIES:
raise HTTPException(409, "Lobby limit reached")
lid, code = uuid.uuid4().hex[:12], _new_code(con)
con.execute("INSERT INTO lobbies (id, name, owner_id, code, created) VALUES (?,?,?,?,?)",
(lid, name, user["id"], code, time.time()))
_event(con, lid, f"{user['username']} created the lobby", user["id"])
return {"id": lid, "code": code}
@api.post("/lobbies/join")
def join_lobby(request: Request, body: dict = Body(...), user: dict = Depends(current_user)):
ip = client_ip(request)
throttle(f"join:{ip}", 10, 600) # only wrong codes count, so guessing is slow
throttle(f"join:u{user['id']}", 10, 600)
code = re.sub(r"[^A-Za-z0-9]", "", str(body.get("code", ""))).upper()
with db() as con:
lobby = con.execute("SELECT * FROM lobbies WHERE code=?", (code,)).fetchone()
if not lobby:
limiter.fail(f"join:{ip}"); limiter.fail(f"join:u{user['id']}")
raise HTTPException(404, "No lobby with this code")
if lobby["owner_id"] == user["id"]:
raise HTTPException(422, "You are the DM of this lobby")
_own_character(con, body.get("character_id"), user)
known = con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lobby["id"], user["id"])).fetchone()
if not known and con.execute("SELECT COUNT(*) FROM lobby_members WHERE lobby_id=?", (lobby["id"],)).fetchone()[0] >= MAX_MEMBERS:
raise HTTPException(409, "This lobby is full")
con.execute("""INSERT INTO lobby_members (lobby_id, user_id, character_id, joined, color) VALUES (?,?,?,?,?)
ON CONFLICT(lobby_id, user_id) DO UPDATE SET character_id=excluded.character_id""",
(lobby["id"], user["id"], body["character_id"], time.time(), _free_color(con, lobby["id"])))
hub.notify(lobby["id"], "map")
_event(con, lobby["id"], f"{user['username']} {'switched to' if known else 'joined as'} {_char_name(con, body['character_id'])}", user["id"])
return {"id": lobby["id"]}
@api.get("/lobbies/{lid}/portrait/{username}")
def lobby_portrait(lid: str, username: str, user: dict = Depends(current_user)):
"""The portrait of a player's character in this lobby (icon on the map and in the player bar). Members only."""
with db() as con:
_lobby_for(con, lid, user)
row = con.execute("""SELECT c.data FROM lobby_members m JOIN users u ON u.id = m.user_id
JOIN characters c ON c.id = m.character_id WHERE m.lobby_id=? AND u.username=?""", (lid, username)).fetchone()
img = portrait_mod.image(json.loads(row["data"]).get("portrait")) if row else None
if not img:
raise HTTPException(404, "No portrait")
return Response(img, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"})
@api.get("/lobbies/{lid}")
def read_lobby(lid: str, user: dict = Depends(current_user)):
rules = load_rules()
with db() as con:
lobby = _lobby_for(con, lid, user)
is_dm = lobby["owner_id"] == user["id"]
_ensure_colors(con, lid)
rows = con.execute("""SELECT m.user_id, m.character_id, m.color, m.char_mod, u.username, c.data FROM lobby_members m
JOIN users u ON u.id = m.user_id LEFT JOIN characters c ON c.id = m.character_id
WHERE m.lobby_id=? ORDER BY m.joined""", (lid,)).fetchall()
combat = combat_view(con, lobby, is_dm)
members = []
online = hub.online(lid)
for r in rows:
mine = r["user_id"] == user["id"]
card = _card(r["data"], rules) if r["data"] else None
# The id is only given out where the viewer may open the full character (the DM sees all, a player only their own)
members.append({"username": r["username"], "me": mine, "character": card, "color": r["color"],
"mod": r["char_mod"] if (is_dm or mine) else None, "online": r["user_id"] in online,
"character_id": r["character_id"] if (is_dm or mine) and card else None})
hidden = bool(lobby["diff_hidden"])
diff = {"label": lobby["diff_label"], "mod": lobby["diff_mod"], "hidden": hidden}
if hidden and not is_dm:
diff = {"label": "", "mod": None, "hidden": True}
return {"id": lobby["id"], "name": lobby["name"], "dm": lobby["dm"], "is_dm": is_dm,
"dm_online": lobby["owner_id"] in online, "combat": combat, "code": lobby["code"] if is_dm else None, "members": members, "difficulty": diff,
"sounds": sounds_mod.settings(lobby["sounds"]), "levels": _levels(rules), "palette": [{"id": i, "hex": h} for i, h in PALETTE], "dm_color": DM_COLOR}
@api.put("/lobbies/{lid}/difficulty")
def set_difficulty(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""The DM's current difficulty: a level from the rules table or a custom modifier. It applies to every check."""
try:
mod = int(body.get("mod", 0))
except (TypeError, ValueError):
raise HTTPException(422, "The modifier must be a whole number")
if not -100 <= mod <= 100:
raise HTTPException(422, "The modifier must be between -100 and 100")
label = (str(body.get("label") or "").strip() or "Custom")[:24]
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
con.execute("UPDATE lobbies SET diff_mod=?, diff_label=?, diff_hidden=? WHERE id=?", (mod, label, 1 if body.get("hidden") else 0, lid))
hub.notify(lid, "lobby")
return {"ok": True}
@api.put("/lobbies/{lid}/members/{username}/mod")
def set_member_mod(lid: str, username: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""A personal bonus or malus of one character (talent, condition ...), added on top of the lobby difficulty."""
try:
mod = int(body.get("mod", 0))
except (TypeError, ValueError):
raise HTTPException(422, "The modifier must be a whole number")
if not -100 <= mod <= 100:
raise HTTPException(422, "The modifier must be between -100 and 100")
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
cur = con.execute("UPDATE lobby_members SET char_mod=? WHERE lobby_id=? AND user_id=(SELECT id FROM users WHERE username=?)",
(mod, lid, username))
if not cur.rowcount:
raise HTTPException(404, "No such player in this lobby")
hub.notify(lid, "lobby")
return {"ok": True}
def _recipient(con, lobby, user: dict, to):
"""User id to whisper to (the DM, a member or yourself for private rolls and notes), None for everybody."""
to = str(to or "").strip()
if not to:
return None
target = con.execute("""SELECT u.id FROM users u WHERE u.username=? AND (u.id=? OR EXISTS
(SELECT 1 FROM lobby_members m WHERE m.lobby_id=? AND m.user_id=u.id))""",
(to, lobby["owner_id"], lobby["id"])).fetchone()
if not target:
raise HTTPException(422, "You can only whisper to people in this lobby")
return target["id"]
@api.get("/lobbies/{lid}/messages")
def read_messages(lid: str, after: int = 0, user: dict = Depends(current_user)):
"""Chat, event log, dice rolls and checks. Whispers are only returned to the sender and the recipient (not even the DM
sees them). Mystery rolls of the DM come without results, hidden-difficulty checks without the numbers behind them.
after=0 returns the latest 100 entries, otherwise everything newer than that id (the client polls)."""
with db() as con:
lobby = _lobby_for(con, lid, user)
is_dm = lobby["owner_id"] == user["id"]
q = """SELECT m.id, m.kind, m.text, m.created, m.user_id, m.to_user_id, f.username AS sender, t.username AS target,
lm.color AS color
FROM lobby_messages m LEFT JOIN users f ON f.id = m.user_id LEFT JOIN users t ON t.id = m.to_user_id
LEFT JOIN lobby_members lm ON lm.lobby_id = m.lobby_id AND lm.user_id = m.user_id
WHERE m.lobby_id=? AND (m.to_user_id IS NULL OR m.to_user_id=? OR m.user_id=?)"""
if after > 0:
rows = con.execute(q + " AND m.id>? ORDER BY m.id LIMIT 200", (lid, user["id"], user["id"], after)).fetchall()
else:
rows = list(reversed(con.execute(q + " ORDER BY m.id DESC LIMIT 100", (lid, user["id"], user["id"])).fetchall()))
out = []
for r in rows:
mine = r["user_id"] == user["id"]
item = {"id": r["id"], "kind": r["kind"], "text": r["text"], "time": r["created"], "from": r["sender"] or "",
"to": r["target"] or "", "mine": mine and r["kind"] != "event",
"color": DM_COLOR if r["user_id"] == lobby["owner_id"] else PALETTE_HEX.get(r["color"], "")}
if r["kind"] == "roll":
roll = json.loads(r["text"])
roll["n"] = len(roll["results"])
if roll.get("mystery") and not mine:
roll["results"] = None # players see that the DM rolled, not what
item["roll"], item["text"] = roll, ""
elif r["kind"] == "check":
chk = json.loads(r["text"])
if chk.get("hidden") and not is_dm:
for k in ("base", "mod", "cmod", "diff", "target", "margin"):
chk.pop(k, None)
item["check"], item["text"] = chk, ""
out.append(item)
return out
@api.post("/lobbies/{lid}/messages")
def send_message(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
text = str(body.get("text", "")).strip()
if not text:
raise HTTPException(422, "Write something first")
if len(text) > MAX_MESSAGE:
raise HTTPException(422, f"Messages can have at most {MAX_MESSAGE} characters")
throttle(f"chat:{user['id']}", 30, 60) # 30 messages per minute
limiter.fail(f"chat:{user['id']}")
with db() as con:
lobby = _lobby_for(con, lid, user)
to_id = _recipient(con, lobby, user, body.get("to"))
cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)",
(lid, user["id"], to_id, "whisper" if to_id else "chat", text, time.time()))
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
hub.notify(lid, "messages", None if to_id is None else {user["id"], to_id})
return {"id": cur.lastrowid}
DICE = (4, 6, 8, 10, 12, 20, 100)
MAX_DICE = 10
def _manual_results(raw, sides: int, count: int):
"""Results typed in by a player who rolled real dice. None when nothing was typed."""
if raw in (None, [], ""):
return None
try:
vals = [int(v) for v in raw]
except (TypeError, ValueError):
raise HTTPException(422, "Enter whole numbers")
if len(vals) != count or any(not 1 <= v <= sides for v in vals):
raise HTTPException(422, f"Enter {count} number{'s' if count > 1 else ''} from 1 to {sides}")
return vals
def _post(con, lid: str, uid: int, to_id, kind: str, payload: dict) -> int:
cur = con.execute("INSERT INTO lobby_messages (lobby_id, user_id, to_user_id, kind, text, created) VALUES (?,?,?,?,?,?)",
(lid, uid, to_id, kind, json.dumps(payload), time.time()))
con.execute("""DELETE FROM lobby_messages WHERE lobby_id=? AND id <= COALESCE(
(SELECT id FROM lobby_messages WHERE lobby_id=? ORDER BY id DESC LIMIT 1 OFFSET ?), 0)""", (lid, lid, KEEP_MESSAGES))
hub.notify(lid, "messages", None if to_id is None else {uid, to_id})
return cur.lastrowid
@api.post("/lobbies/{lid}/rolls")
def roll_dice(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""Rolls on the server (so nobody can cheat) and posts the result in the chat; with `to` it is a private roll.
Players with real dice can send their own results in `manual` (marked as such in the chat).
The DM picks `mode`: hidden (only the DM sees it), mystery (players see that dice were rolled, not the result) or public."""
try:
sides, count = int(body.get("sides")), int(body.get("count", 1))
except (TypeError, ValueError):
raise HTTPException(422, "Invalid dice")
if sides not in DICE or not 1 <= count <= MAX_DICE:
raise HTTPException(422, f"Roll 1 to {MAX_DICE} dice of: " + ", ".join(f"d{d}" for d in DICE))
throttle(f"chat:{user['id']}", 30, 60)
limiter.fail(f"chat:{user['id']}")
manual = _manual_results(body.get("manual"), sides, count)
results = manual or [secrets.randbelow(sides) + 1 for _ in range(count)]
with db() as con:
lobby = _lobby_for(con, lid, user)
is_dm = lobby["owner_id"] == user["id"]
to_id = _recipient(con, lobby, user, body.get("to"))
mystery = False
if is_dm and to_id is None:
mode = body.get("mode") or "hidden"
if mode not in ("hidden", "mystery", "public"):
raise HTTPException(422, "Unknown roll mode")
if mode == "hidden":
to_id = user["id"] # only the DM sees it
mystery = mode == "mystery"
mid = _post(con, lid, user["id"], to_id, "roll", {"sides": sides, "results": results, "mystery": mystery, "manual": bool(manual)})
return {"id": mid, "sides": sides, "results": results}
@api.post("/lobbies/{lid}/checks")
def roll_check(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""A d100 roll-under check for a skill or attribute of the player's current lobby character.
Target = attribute + skill + the DM's difficulty + the character's personal modifier. 01-05 always succeeds, 96-100 always fails."""
rules = load_rules()
kind, key = str(body.get("kind", "")), str(body.get("id", ""))
throttle(f"chat:{user['id']}", 30, 60)
limiter.fail(f"chat:{user['id']}")
with db() as con:
lobby = _lobby_for(con, lid, user)
member = con.execute("""SELECT m.char_mod, c.data FROM lobby_members m LEFT JOIN characters c ON c.id = m.character_id
WHERE m.lobby_id=? AND m.user_id=?""", (lid, user["id"])).fetchone()
npc_name = ""
if body.get("npc_token"): # the DM rolls for an NPC on the table
_dm_only(lobby, user)
t = con.execute("""SELECT t.name, c.data FROM lobby_tokens t JOIN characters c ON c.id = t.npc_id
WHERE t.id=? AND t.lobby_id=?""", (str(body["npc_token"]), lid)).fetchone()
if not t:
raise HTTPException(404, "This token has no NPC sheet")
member, npc_name = {"char_mod": 0, "data": t["data"]}, t["name"]
if not member or not member["data"]:
raise HTTPException(422, "Only players with a character in this lobby can roll checks")
c = compute(json.loads(member["data"]), rules)
if kind == "skill":
s = next((x for x in c["skills"] if x["id"] == key), None)
if not s:
raise HTTPException(404, "Unknown skill")
if not s["usable"]:
raise HTTPException(422, "This skill is still locked")
what, base = (f"{npc_name}: " if npc_name else "") + s["name"], s["total"]
elif kind == "attr":
a = next((x for x in rules["attributes"] if x["id"] == key), None)
if not a:
raise HTTPException(404, "Unknown attribute")
what, base = (f"{npc_name}: " if npc_name else "") + a["name"], c["attrs"][key]
else:
raise HTTPException(422, "Roll a skill or an attribute")
manual = _manual_results(None if body.get("manual") in (None, "") else [body.get("manual")], 100, 1)
roll = manual[0] if manual else secrets.randbelow(100) + 1
target = base + lobby["diff_mod"] + member["char_mod"]
outcome = "crit_success" if roll <= 5 else "crit_failure" if roll >= 96 else "success" if roll <= target else "failure"
payload = {"kind": kind, "what": what, "base": base, "mod": lobby["diff_mod"], "cmod": member["char_mod"], "diff": lobby["diff_label"],
"target": target, "roll": roll, "margin": target - roll, "outcome": outcome, "hidden": bool(lobby["diff_hidden"]),
"manual": bool(manual)}
private = str(body.get("to") or "") == user["username"] # a private check is only seen by the roller
mid = _post(con, lid, user["id"], user["id"] if private else None, "check", payload)
return {"id": mid, "roll": roll, "outcome": outcome}
@api.put("/lobbies/{lid}")
def rename_lobby(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
name = str(body.get("name", "")).strip()[:60]
if not name:
raise HTTPException(422, "Give the lobby a name")
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
con.execute("UPDATE lobbies SET name=? WHERE id=?", (name, lid))
_event(con, lid, f"The lobby was renamed to \"{name}\"", user["id"])
return {"ok": True}
@api.delete("/lobbies/{lid}")
def delete_lobby(lid: str, user: dict = Depends(current_user)):
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
con.execute("DELETE FROM lobby_members WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_messages WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobby_tokens WHERE lobby_id=?", (lid,))
con.execute("DELETE FROM lobbies WHERE id=?", (lid,))
purge_sounds(lid)
hub.close_lobby(lid)
return {"ok": True}
@api.post("/lobbies/{lid}/code")
def new_lobby_code(lid: str, user: dict = Depends(current_user)):
"""New join code, the old one stops working (members stay)."""
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
code = _new_code(con)
con.execute("UPDATE lobbies SET code=? WHERE id=?", (code, lid))
_event(con, lid, "The DM created a new join code", user["id"])
return {"code": code}
@api.put("/lobbies/{lid}/me")
def update_my_membership(lid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""Switch your character and/or pick your colour (no two players share one)."""
with db() as con:
_lobby_for(con, lid, user)
if not con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone():
raise HTTPException(422, "You are the DM of this lobby")
if "character_id" not in body and "color" not in body:
raise HTTPException(422, "Nothing to change")
if "color" in body:
color = body["color"]
if color not in PALETTE_HEX:
raise HTTPException(422, "Unknown colour")
if con.execute("SELECT 1 FROM lobby_members WHERE lobby_id=? AND color=? AND user_id!=?", (lid, color, user["id"])).fetchone():
raise HTTPException(409, "Another player already uses this colour")
con.execute("UPDATE lobby_members SET color=? WHERE lobby_id=? AND user_id=?", (color, lid, user["id"]))
hub.notify(lid, "lobby")
if "character_id" in body:
_own_character(con, body.get("character_id"), user)
con.execute("UPDATE lobby_members SET character_id=? WHERE lobby_id=? AND user_id=?", (body["character_id"], lid, user["id"]))
_event(con, lid, f"{user['username']} switched to {_char_name(con, body['character_id'])}", user["id"])
return {"ok": True}
@api.delete("/lobbies/{lid}/me")
def leave_lobby(lid: str, user: dict = Depends(current_user)):
with db() as con:
_lobby_for(con, lid, user)
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).rowcount
if gone:
_event(con, lid, f"{user['username']} left the lobby", user["id"])
if gone:
hub.notify(lid, "map")
hub.close_user(lid, user["id"])
return {"ok": True}
@api.delete("/lobbies/{lid}/members/{username}")
def kick_member(lid: str, username: str, user: dict = Depends(current_user)):
with db() as con:
_dm_only(_lobby_for(con, lid, user), user)
gone = con.execute("DELETE FROM lobby_members WHERE lobby_id=? AND user_id=(SELECT id FROM users WHERE username=?)",
(lid, username)).rowcount
if gone:
_event(con, lid, f"{username} was removed by the DM", user["id"])
gone_id = con.execute("SELECT id FROM users WHERE username=?", (username,)).fetchone()["id"]
if gone:
hub.notify(lid, "map")
hub.close_user(lid, gone_id)
return {"ok": True}
@api.post("/claim/{kind}/{rid}")
def claim(kind: str, rid: str, body: dict = Body(...), user: dict = Depends(current_user)):
"""Takes over something created before accounts existed, using its old secret key."""
table = {"characters": "characters", "presets": "presets"}.get(kind)
if not table:
raise HTTPException(404, "Unknown kind")
throttle(f"claim:{user['id']}", 60, 600)
with db() as con:
row = con.execute(f"SELECT token, owner_id FROM {table} WHERE id=?", (rid,)).fetchone()
key = str(body.get("token", ""))
if not row or row["owner_id"] is not None or not row["token"] or not secrets.compare_digest(row["token"], key):
limiter.fail(f"claim:{user['id']}")
raise HTTPException(404, "Nothing to claim")
con.execute(f"UPDATE {table} SET owner_id=?, token='' WHERE id=?", (user["id"], rid))
return {"ok": True}
# ---- export
def _filename(ch: dict, ext: str) -> str:
base = re.sub(r"[^A-Za-z0-9_-]+", "_", ch.get("name") or "Character").strip("_") or "Character"
return f"{base}.{ext}"
@api.post("/export/{fmt}")
def export(fmt: str, ch: dict = Body(...), user: dict = Depends(current_user)):
check_size(ch)
rules = all_rules(user["id"])
if fmt == "docx":
data = to_docx(ch, rules)
mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
elif fmt == "pdf":
data = to_pdf(ch, rules)
mime = "application/pdf"
else:
raise HTTPException(404, "Unknown format")
return Response(data, media_type=mime,
headers={"Content-Disposition": f'attachment; filename="{_filename(ch, fmt)}"'})
# ---- rules editor (admins only)
def admin_user(user: dict = Depends(current_user)) -> dict:
if not is_admin(user):
raise HTTPException(403, "Only admins can edit the rules")
return user
@api.get("/admin/users")
def admin_users(user: dict = Depends(admin_user)):
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
with db() as con:
ids = admin_ids(con)
rows = con.execute("SELECT id, username, created FROM users ORDER BY username COLLATE NOCASE").fetchall()
return [{"username": r["username"], "admin": r["id"] in ids, "fixed": r["username"].lower() in names, "me": r["id"] == user["id"],
"created": r["created"]} for r in rows]
@api.put("/admin/users/{username}")
def set_admin(username: str, body: dict = Body(...), user: dict = Depends(admin_user)):
"""Make a user an admin or take it away again. Names listed in AW_ADMINS can only be changed in the server settings."""
want = bool(body.get("admin"))
names = {n.strip().lower() for n in os.environ.get("AW_ADMINS", "").split(",") if n.strip()}
with db() as con:
target = con.execute("SELECT id, username FROM users WHERE username=?", (username,)).fetchone()
if not target:
raise HTTPException(404, "No user with this name")
ids = admin_ids(con)
if want:
if not any(r["is_admin"] for r in con.execute("SELECT is_admin FROM users")) and not names: # the implicit first-account admin becomes explicit
con.execute("UPDATE users SET is_admin=1 WHERE id IN (%s)" % ",".join(str(i) for i in ids))
con.execute("UPDATE users SET is_admin=1 WHERE id=?", (target["id"],))
else:
if target["username"].lower() in names:
raise HTTPException(409, "This admin is set in AW_ADMINS on the server and can only be removed there")
if ids - {target["id"]} == set():
raise HTTPException(409, "There must be at least one admin")
if target["id"] not in ids:
return {"ok": True}
if not any(r["is_admin"] for r in con.execute("SELECT is_admin FROM users")): # implicit first account: make it explicit first
con.execute("UPDATE users SET is_admin=1 WHERE id IN (%s)" % ",".join(str(i) for i in ids))
con.execute("UPDATE users SET is_admin=0 WHERE id=?", (target["id"],))
return {"ok": True}
@api.get("/admin/rules")
def editor_state(user: dict = Depends(admin_user)):
"""The editable sections as they are now, and which of them differ from the shipped rules.json."""
r = load_rules()
return {"sections": {k: r[k] for k in rules_edit.SECTIONS}, "customized": [k for k in read_overrides() if k in rules_edit.SECTIONS]}
def _all_characters(con) -> list:
return [json.loads(x["data"]) for x in con.execute("SELECT data FROM characters")]
def _save_overrides(data: dict):
OVERRIDES_PATH.parent.mkdir(parents=True, exist_ok=True)
if OVERRIDES_PATH.exists():
OVERRIDES_PATH.with_suffix(".json.bak").write_bytes(OVERRIDES_PATH.read_bytes())
tmp = OVERRIDES_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, OVERRIDES_PATH)
def _apply(section: str, new: list, confirm: bool):
r = load_rules()
old_ids = {x["id"] for x in r[section]}
removed = old_ids - {x["id"] for x in new}
bad = rules_edit.dangling(section, new, r)
if bad:
raise HTTPException(422, "Cannot save: " + "; ".join(bad[:5]) + (" ..." if len(bad) > 5 else ""))
with db() as con:
used = rules_edit.usage(_all_characters(con), section, removed)
if used and not confirm:
raise HTTPException(409, f"{used} saved character{'s' if used != 1 else ''} use{'' if used != 1 else 's'} entries you are removing. "
"Their data stays in the characters but the removed entries disappear from the sheets.")
return used
@api.put("/admin/rules/{section}")
def editor_save(section: str, body: dict = Body(...), user: dict = Depends(admin_user)):
try:
new = rules_edit.clean(section, body.get("items"), load_rules())
except rules_edit.RuleError as e:
raise HTTPException(422, str(e))
_apply(section, new, bool(body.get("confirm")))
data = read_overrides()
data[section] = new
_save_overrides(data)
return {"ok": True, "count": len(new)}
@api.delete("/admin/rules/{section}")
def editor_reset(section: str, confirm: bool = False, user: dict = Depends(admin_user)):
"""Back to the shipped rules.json for this section."""
if section not in rules_edit.SECTIONS:
raise HTTPException(404, "Unknown section")
data = read_overrides()
if section in data:
_apply(section, base_rules()[section], confirm)
del data[section]
_save_overrides(data)
return {"ok": True}
maps_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, Path(DB_PATH).parent / "maps", _can_read_character)
combat_view = combat_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, _event, load_rules)
purge_sounds = sounds_mod.install(api, db, current_user, _lobby_for, _dm_only, hub, Path(DB_PATH).parent / "sounds")
@app.websocket("/api/lobbies/{lid}/ws")
async def lobby_socket(websocket: WebSocket, lid: str):
"""One socket per open lobby page. Only notes go out ("messages", "lobby", "sheet"), the data is fetched through the API."""
origin = websocket.headers.get("origin")
hosts = {websocket.headers.get("host", "")}
if TRUST_PROXY:
hosts.add(websocket.headers.get("x-forwarded-host", ""))
if origin and origin.split("://", 1)[-1] not in hosts:
await websocket.close(code=4403)
return
def check():
with db() as con:
user = auth.user_for_token(con, websocket.cookies.get(auth.SESSION_COOKIE))
if not user:
return None
lobby = con.execute("SELECT owner_id FROM lobbies WHERE id=?", (lid,)).fetchone()
ok = lobby and (lobby["owner_id"] == user["id"] or con.execute(
"SELECT 1 FROM lobby_members WHERE lobby_id=? AND user_id=?", (lid, user["id"])).fetchone())
return user if ok else None
user = await asyncio.to_thread(check)
if not user:
await websocket.close(code=4401)
return
await websocket.accept()
was_online = user["id"] in hub.online(lid)
if not hub.add(lid, websocket, user["id"]):
await websocket.close(code=4429)
return
if not was_online:
hub.notify(lid, "lobby") # presence changed
try:
last_check = time.time()
while True:
try:
text = await asyncio.wait_for(websocket.receive_text(), timeout=70)
except asyncio.TimeoutError:
break # the client pings every 25 seconds, silence means it is gone
if text == "ping":
await websocket.send_text("pong")
if time.time() - last_check > 300: # session or membership may have ended
last_check = time.time()
if not await asyncio.to_thread(check):
break
except WebSocketDisconnect:
pass
finally:
hub.remove(lid, websocket)
if user["id"] not in hub.online(lid):
hub.notify(lid, "lobby")
try:
await websocket.close()
except Exception:
pass
app.include_router(api)
# Serve the built frontend with an SPA fallback (the app shell is public, all data is behind the API login)
if STATIC.is_dir():
app.mount("/assets", StaticFiles(directory=STATIC / "assets"), name="assets")
@app.get("/{path:path}")
def spa(path: str):
if path.startswith("api/"):
raise HTTPException(404, "Not found")
f = (STATIC / path).resolve()
if path and f.is_file() and STATIC in f.parents:
return FileResponse(f)
return FileResponse(STATIC / "index.html")