Add accounts, presets, live sheet panel and equipment tiles
- Login with registration code, sessions, rate limiting, per-user characters - Share characters with other users (read-only), admin CLI - Rules presets with a Presets tab, applied/pending changes with diff in a side sheet - Equipment as tiles with amounts, starting kit removed - Rename nav tab to "Character", add "New character" button - Stop tracking _to_delete/ Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
"""Small admin CLI: python -m app.admin list | passwd <user> | delete <user>"""
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
from . import auth
|
||||
from .main import db
|
||||
|
||||
|
||||
def main(argv):
|
||||
if not argv:
|
||||
print(__doc__)
|
||||
return 1
|
||||
cmd = argv[0]
|
||||
with db() as con:
|
||||
if cmd == "list":
|
||||
for r in con.execute("SELECT username, created FROM users ORDER BY id"):
|
||||
print(r["username"])
|
||||
elif cmd == "passwd" and len(argv) == 2:
|
||||
pw = getpass.getpass("New password: ")
|
||||
if len(pw) < auth.MIN_PASSWORD:
|
||||
print(f"Password must have at least {auth.MIN_PASSWORD} characters.")
|
||||
return 1
|
||||
cur = con.execute("UPDATE users SET pw_hash=? WHERE username=?", (auth.hash_password(pw), argv[1]))
|
||||
if not cur.rowcount:
|
||||
print("No such user.")
|
||||
return 1
|
||||
con.execute("DELETE FROM sessions WHERE user_id=(SELECT id FROM users WHERE username=?)", (argv[1],))
|
||||
print("Password changed, user logged out everywhere.")
|
||||
elif cmd == "delete" and len(argv) == 2:
|
||||
row = con.execute("SELECT id FROM users WHERE username=?", (argv[1],)).fetchone()
|
||||
if not row:
|
||||
print("No such user.")
|
||||
return 1
|
||||
con.execute("DELETE FROM sessions WHERE user_id=?", (row["id"],))
|
||||
con.execute("DELETE FROM shares WHERE user_id=?", (row["id"],))
|
||||
con.execute("DELETE FROM users WHERE id=?", (row["id"],))
|
||||
print("User deleted. Their characters and presets stay on the server without an owner.")
|
||||
else:
|
||||
print(__doc__)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Accounts, sessions and brute-force protection (standard library only)."""
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
|
||||
SESSION_COOKIE = "aw_session"
|
||||
SESSION_DAYS = 30
|
||||
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$")
|
||||
MIN_PASSWORD = 8
|
||||
MAX_PASSWORD = 200
|
||||
|
||||
# scrypt parameters (about 16 MB of memory per hash)
|
||||
_N, _R, _P = 2 ** 14, 8, 1
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
h = hashlib.scrypt(password.encode(), salt=salt, n=_N, r=_R, p=_P, dklen=32)
|
||||
return f"scrypt${_N}${_R}${_P}${salt.hex()}${h.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
_, n, r, p, salt, h = stored.split("$")
|
||||
calc = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=int(n), r=int(r), p=int(p), dklen=32)
|
||||
return hmac.compare_digest(calc.hex(), h)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
_DUMMY = hash_password("dummy-password-for-timing")
|
||||
|
||||
|
||||
def init_tables(con: sqlite3.Connection) -> None:
|
||||
con.execute("""CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
pw_hash TEXT NOT NULL, created REAL NOT NULL)""")
|
||||
con.execute("""CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL, expires REAL NOT NULL)""")
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def create_session(con: sqlite3.Connection, user_id: int) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
con.execute("DELETE FROM sessions WHERE expires < ?", (time.time(),))
|
||||
con.execute("INSERT INTO sessions VALUES (?,?,?)", (_hash_token(token), user_id, time.time() + SESSION_DAYS * 86400))
|
||||
return token
|
||||
|
||||
|
||||
def user_for_token(con: sqlite3.Connection, token: str | None):
|
||||
if not token:
|
||||
return None
|
||||
row = con.execute(
|
||||
"SELECT u.id, u.username, s.expires FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?",
|
||||
(_hash_token(token),)).fetchone()
|
||||
if not row or row["expires"] < time.time():
|
||||
return None
|
||||
return {"id": row["id"], "username": row["username"]}
|
||||
|
||||
|
||||
def end_session(con: sqlite3.Connection, token: str | None) -> None:
|
||||
if token:
|
||||
con.execute("DELETE FROM sessions WHERE token_hash = ?", (_hash_token(token),))
|
||||
|
||||
|
||||
def timing_dummy(password: str) -> None:
|
||||
"""Burn the same time as a real check when the user does not exist."""
|
||||
verify_password(password, _DUMMY)
|
||||
|
||||
|
||||
class Limiter:
|
||||
"""In-memory failure counter: too many failures within the window block further attempts."""
|
||||
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.hits: dict[str, list[float]] = {}
|
||||
|
||||
def blocked(self, key: str, limit: int, window: int) -> int:
|
||||
"""Returns seconds to wait (0 = allowed)."""
|
||||
now = time.time()
|
||||
with self.lock:
|
||||
xs = [t for t in self.hits.get(key, []) if now - t < window]
|
||||
self.hits[key] = xs
|
||||
return int(window - (now - xs[0])) + 1 if len(xs) >= limit else 0
|
||||
|
||||
def fail(self, key: str) -> None:
|
||||
with self.lock:
|
||||
self.hits.setdefault(key, []).append(time.time())
|
||||
if len(self.hits) > 20000: # keep memory bounded
|
||||
self.hits.clear()
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
with self.lock:
|
||||
self.hits.pop(key, None)
|
||||
|
||||
|
||||
def registration_code() -> str:
|
||||
return os.environ.get("AW_REGISTRATION_CODE", "")
|
||||
+9
-12
@@ -2,20 +2,17 @@
|
||||
|
||||
|
||||
def kit_items(ch: dict, rules: dict) -> list:
|
||||
"""Returns [(label, item, qty)] for the starting kit and extra items."""
|
||||
"""Returns [(category, item, qty)] for the carried items. Old starting-kit slots count as one item each."""
|
||||
items = {e["id"]: e for e in rules.get("equipment", [])}
|
||||
cats = {c["id"]: c["name"] for c in rules.get("equipment_categories", [])}
|
||||
kit = ch.get("kit") or {}
|
||||
out = []
|
||||
for label, key in (("Weapon", "weapon"), ("Armor", "armor"), ("Tool", "tool")):
|
||||
if kit.get(key) in items:
|
||||
out.append((label, items[kit[key]], 1))
|
||||
for rid in kit.get("resources", []):
|
||||
if rid in items:
|
||||
out.append(("Resource", items[rid], 1))
|
||||
for iid, qty in (kit.get("extras") or {}).items():
|
||||
if iid in items and int(qty) > 0:
|
||||
out.append(("Extra", items[iid], int(qty)))
|
||||
return out
|
||||
qty: dict = {}
|
||||
for iid in [kit.get("weapon"), kit.get("armor"), kit.get("tool"), *(kit.get("resources") or [])]:
|
||||
if iid:
|
||||
qty[iid] = qty.get(iid, 0) + 1
|
||||
for iid, n in (kit.get("extras") or {}).items():
|
||||
qty[iid] = qty.get(iid, 0) + int(n)
|
||||
return [(cats.get(items[i]["cat"], ""), items[i], n) for i, n in qty.items() if i in items and n > 0]
|
||||
|
||||
|
||||
def compute(ch: dict, rules: dict) -> dict:
|
||||
|
||||
@@ -40,8 +40,8 @@ def _sheet(ch: dict, rules: dict) -> dict:
|
||||
"derived_rows": [[d["name"], str(c["derived"][d["id"]])] for d in rules["derived"]],
|
||||
"skill_rows": [
|
||||
[s["name"], "/".join(a["short"] for a in rules["attributes"] if a["id"] in s["attrs"]),
|
||||
str(s["points"]), {0: "", 1: "Proficient", 2: "Expertise"}.get(s["pips"], ""), str(s["total"])]
|
||||
for s in c["skills"] if s["points"] or s["prof"] or (s["unlocked"] and s["type"] == "advanced")
|
||||
str(s["points"]), {1: "Proficient", 2: "Expertise"}.get(s["pips"]) or ("" if s["usable"] else "Locked"), str(s["total"])]
|
||||
for s in c["skills"]
|
||||
],
|
||||
"augment_rows": [[a["name"], KIND.get(a["kind"], a["kind"]), SOURCE.get(a["source"]) or "-", str(a["cost"])]
|
||||
for a in c["augments"]],
|
||||
@@ -92,7 +92,7 @@ def to_docx(ch: dict, rules: dict) -> bytes:
|
||||
doc.add_paragraph("-")
|
||||
doc.add_heading(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", 1)
|
||||
if s["gear_rows"]:
|
||||
table(["", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"])
|
||||
table(["Category", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"])
|
||||
else:
|
||||
doc.add_paragraph("-")
|
||||
if s["other"]:
|
||||
@@ -166,7 +166,7 @@ def to_pdf(ch: dict, rules: dict) -> bytes:
|
||||
|
||||
el.append(Paragraph(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", h2))
|
||||
if s["gear_rows"]:
|
||||
el.append(tbl(["", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"], [22 * mm, 65 * mm, 55 * mm, 22 * mm]))
|
||||
el.append(tbl(["Category", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"], [30 * mm, 60 * mm, 50 * mm, 22 * mm]))
|
||||
else:
|
||||
el.append(Paragraph("-", body))
|
||||
if s["other"]:
|
||||
|
||||
+301
-58
@@ -7,10 +7,11 @@ import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Body, FastAPI, Header, HTTPException, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import auth
|
||||
from .export import to_docx, to_pdf
|
||||
|
||||
BASE = Path(__file__).resolve().parent.parent
|
||||
@@ -19,8 +20,14 @@ 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
|
||||
|
||||
# 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()
|
||||
TRUST_PROXY = os.environ.get("AW_TRUST_PROXY", "") == "1" # behind a reverse proxy that sets X-Forwarded-For / -Proto
|
||||
|
||||
app = FastAPI(title="Arcane Wasteland Character Creator")
|
||||
|
||||
|
||||
def load_rules() -> dict:
|
||||
@@ -38,19 +45,28 @@ def db() -> sqlite3.Connection:
|
||||
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))""")
|
||||
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() -> list:
|
||||
with db() as con:
|
||||
rows = con.execute("SELECT id, data FROM presets ORDER BY created").fetchall()
|
||||
return [{"id": r["id"], **json.loads(r["data"]), "custom": True} for r in rows]
|
||||
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() -> dict:
|
||||
def all_rules(uid=None) -> dict:
|
||||
"""Rules from rules.json plus the presets created in the app."""
|
||||
r = load_rules()
|
||||
r["presets"] = [{**p, "builtin": True} for p in r.get("presets", [])] + custom_presets()
|
||||
with db() as con:
|
||||
custom = custom_presets(con, uid)
|
||||
r["presets"] = [{**p, "builtin": True} for p in r.get("presets", [])] + custom
|
||||
return r
|
||||
|
||||
|
||||
@@ -76,99 +92,321 @@ def check_size(ch: dict):
|
||||
raise HTTPException(413, "Character too large")
|
||||
|
||||
|
||||
@app.get("/api/rules")
|
||||
def get_rules():
|
||||
return all_rules()
|
||||
# ---------------------------------------------------------------- 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 "?"
|
||||
|
||||
|
||||
@app.post("/api/presets")
|
||||
def create_preset(body: dict = Body(...)):
|
||||
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"
|
||||
resp.headers["Content-Security-Policy"] = ("default-src 'self'; style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; frame-ancestors 'none'")
|
||||
if request.url.path.startswith("/api/"):
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
|
||||
|
||||
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))
|
||||
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)
|
||||
return {"user": {"id": cur.lastrowid, "username": username}}
|
||||
|
||||
|
||||
@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)
|
||||
return {"user": {"id": row["id"], "username": row["username"]}}
|
||||
|
||||
|
||||
@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, token, now = "u-" + uuid.uuid4().hex[:8], secrets.token_urlsafe(16), time.time()
|
||||
con.execute("INSERT INTO presets VALUES (?,?,?,?,?)", (pid, token, json.dumps(data), now, now))
|
||||
return {"id": pid, "token": token}
|
||||
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}
|
||||
|
||||
|
||||
@app.put("/api/presets/{pid}")
|
||||
def update_preset(pid: str, body: dict = Body(...), x_edit_token: str = Header(default="")):
|
||||
@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:
|
||||
row = con.execute("SELECT token FROM presets WHERE id=?", (pid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Preset not found")
|
||||
if not secrets.compare_digest(row["token"], x_edit_token):
|
||||
raise HTTPException(403, "No write access to this preset")
|
||||
_owned(con, "presets", pid, user)
|
||||
con.execute("UPDATE presets SET data=?, updated=? WHERE id=?", (json.dumps(data), time.time(), pid))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.delete("/api/presets/{pid}")
|
||||
def delete_preset(pid: str, x_edit_token: str = Header(default="")):
|
||||
@api.delete("/presets/{pid}")
|
||||
def delete_preset(pid: str, user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
row = con.execute("SELECT token FROM presets WHERE id=?", (pid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Preset not found")
|
||||
if not secrets.compare_digest(row["token"], x_edit_token):
|
||||
raise HTTPException(403, "No write access to this preset")
|
||||
_owned(con, "presets", pid, user)
|
||||
con.execute("DELETE FROM presets WHERE id=?", (pid,))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/characters")
|
||||
def create_character(ch: dict = Body(...)):
|
||||
# ---- 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"
|
||||
|
||||
|
||||
@api.get("/characters")
|
||||
def my_characters(user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
rows = con.execute("SELECT id, data, updated FROM characters WHERE owner_id=? ORDER BY updated DESC",
|
||||
(user["id"],)).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=? ORDER BY u.username""",
|
||||
(user["id"],)):
|
||||
shares.setdefault(r["character_id"], []).append(r["username"])
|
||||
shared = con.execute("""SELECT c.id, c.data, c.updated, 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=? ORDER BY c.updated DESC""", (user["id"],)).fetchall()
|
||||
return {"mine": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "shared_with": shares.get(r["id"], [])}
|
||||
for r in rows],
|
||||
"shared": [{"id": r["id"], "name": _name(r["data"]), "updated": r["updated"], "owner": r["owner"] or "?"}
|
||||
for r in shared]}
|
||||
|
||||
|
||||
@api.post("/characters")
|
||||
def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
|
||||
check_size(ch)
|
||||
cid, token, now = uuid.uuid4().hex[:12], secrets.token_urlsafe(16), time.time()
|
||||
with db() as con:
|
||||
con.execute("INSERT INTO characters VALUES (?,?,?,?,?)", (cid, token, json.dumps(ch), now, now))
|
||||
return {"id": cid, "token": token}
|
||||
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) VALUES (?,?,?,?,?,?)",
|
||||
(cid, "", json.dumps(ch), now, now, user["id"]))
|
||||
return {"id": cid}
|
||||
|
||||
|
||||
@app.get("/api/characters/{cid}")
|
||||
def read_character(cid: str):
|
||||
@api.get("/characters/{cid}")
|
||||
def read_character(cid: str, user: dict = Depends(current_user)):
|
||||
"""Only the owner and the users the character is shared with may read it."""
|
||||
with db() as con:
|
||||
row = con.execute("SELECT data FROM characters WHERE id=?", (cid,)).fetchone()
|
||||
if not row:
|
||||
row = con.execute("SELECT data, owner_id FROM characters WHERE id=?", (cid,)).fetchone()
|
||||
allowed = bool(row) and (row["owner_id"] == user["id"] or con.execute(
|
||||
"SELECT 1 FROM shares WHERE character_id=? AND user_id=?", (cid, user["id"])).fetchone())
|
||||
if not allowed:
|
||||
raise HTTPException(404, "Character not found")
|
||||
return json.loads(row["data"])
|
||||
|
||||
|
||||
@app.put("/api/characters/{cid}")
|
||||
def update_character(cid: str, ch: dict = Body(...), x_edit_token: str = Header(default="")):
|
||||
@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)):
|
||||
check_size(ch)
|
||||
with db() as con:
|
||||
row = con.execute("SELECT token FROM characters WHERE id=?", (cid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Character not found")
|
||||
if not secrets.compare_digest(row["token"], x_edit_token):
|
||||
raise HTTPException(403, "No write access to this character")
|
||||
_owned(con, "characters", cid, user)
|
||||
con.execute("UPDATE characters SET data=?, updated=? WHERE id=?", (json.dumps(ch), time.time(), cid))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.delete("/api/characters/{cid}")
|
||||
def delete_character(cid: str, x_edit_token: str = Header(default="")):
|
||||
@api.delete("/characters/{cid}")
|
||||
def delete_character(cid: str, user: dict = Depends(current_user)):
|
||||
with db() as con:
|
||||
row = con.execute("SELECT token FROM characters WHERE id=?", (cid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Character not found")
|
||||
if not secrets.compare_digest(row["token"], x_edit_token):
|
||||
raise HTTPException(403, "No write access to this character")
|
||||
_owned(con, "characters", cid, user)
|
||||
con.execute("DELETE FROM characters WHERE id=?", (cid,))
|
||||
con.execute("DELETE FROM shares WHERE character_id=?", (cid,))
|
||||
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}"
|
||||
|
||||
|
||||
@app.post("/api/export/{fmt}")
|
||||
def export(fmt: str, ch: dict = Body(...)):
|
||||
@api.post("/export/{fmt}")
|
||||
def export(fmt: str, ch: dict = Body(...), user: dict = Depends(current_user)):
|
||||
check_size(ch)
|
||||
rules = all_rules()
|
||||
rules = all_rules(user["id"])
|
||||
if fmt == "docx":
|
||||
data = to_docx(ch, rules)
|
||||
mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
@@ -181,12 +419,17 @@ def export(fmt: str, ch: dict = Body(...)):
|
||||
headers={"Content-Disposition": f'attachment; filename="{_filename(ch, fmt)}"'})
|
||||
|
||||
|
||||
# Serve the built frontend with an SPA fallback
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user