diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d523c9a --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy this file to ".env" (it is ignored by git) and fill it in. +# The code people must enter to create an account: +AW_REGISTRATION_CODE= +# 1 when running behind a reverse proxy that sets X-Forwarded-For / X-Forwarded-Proto / X-Forwarded-Host: +AW_TRUST_PROXY=0 diff --git a/.gitignore b/.gitignore index 5d96cac..a170dba 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ backend/__pycache__/ backend/app/__pycache__/ backend/aw.db db/ +.env +_to_delete/ diff --git a/README.md b/README.md index 08cae7a..fafa75c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,29 @@ Website for creating characters for the Arcane Wasteland TTRPG, with a searchabl docker compose up -d --build ``` -Then open `http://:8080`. Characters are stored in `./db/aw.db`. For HTTPS and a domain, put the container behind a reverse proxy (nginx, Caddy, Traefik). +Then open `http://:8080`. Users, characters and presets are stored in `./db/aw.db`. + +### Login and accounts + +Everything (the whole API, rules, characters) is behind a login. The only public things are the login page itself and the app's JavaScript. + +1. Copy `.env.example` to `.env` and set `AW_REGISTRATION_CODE`. People need this code **once** to create an account; with an empty code registration is disabled. +2. Open the site, choose "Create account", enter a username, a password (min. 8 characters) and the registration code. +3. Sessions last 30 days (HttpOnly cookie, stored server-side). Logging out or changing the password ends them. + +Wrong logins and wrong registration codes are throttled (10 failures per address, 8 per username, 5 wrong codes per address and 30 per hour for the whole server), so a short code is not trivially guessable, but a long one is still better. Passwords are stored as scrypt hashes. + +**HTTPS is a must when the site is reachable from the internet**, otherwise passwords travel in plain text. Put the container behind a reverse proxy (Caddy, nginx, Traefik) that terminates TLS, pass the `Host` header through, and set `AW_TRUST_PROXY=1` in `.env` so the app sees the real client address and marks the cookie `Secure`. + +Admin tasks (list users, reset a password, delete a user): + +```bash +docker compose exec awcc python -m app.admin list +docker compose exec awcc python -m app.admin passwd +docker compose exec awcc python -m app.admin delete +``` + +Characters and presets made before accounts existed are adopted automatically by the account that logs in from the same browser (the old secret keys are used once and then removed). ## Edit the rules @@ -36,7 +58,7 @@ In the creator, the Concept page only has a "Rules preset" selector. Presets are ### Equipment -`equipment` lists every item with `id`, `name`, `cat` (category), `stat` (damage / armor / type), `kg` and `note`. The starting-kit rules are in `creation.kit` (allowed weapon categories, armor tiers, number of resources). Weights are approximate; for ranges in the source document the upper value is used. +`equipment` lists every item with `id`, `name`, `cat` (category), `stat` (damage / armor / type), `kg` and `note`. There are no starting-kit rules: players pick any items (with an amount) on the Equipment page and the total weight is checked against the carry capacity. Weights are approximate; for ranges in the source document the upper value is used. ## Development @@ -51,9 +73,7 @@ cd frontend && npm install && npm run dev ## Saving and sharing -There are no user accounts. When saved, a character gets an ID and a secret write key that stays in the browser ("My characters" page). The link `/#creator/` lets others view the character; only the holder of the key can change it. To move to another browser use the JSON backup (Summary page: "JSON backup" and "Import JSON"). - -If the site is reachable from the internet, protect it with Basic Auth at the reverse proxy. +Characters are saved in your account ("My characters" page). Only the owner can see a character, change it or delete it. On "My characters" the **Share** button lets you add usernames (e.g. your DM): they get read-only access, see the character under "Shared with me" and can open it through `/#creator/`. Saving a viewed character creates their own copy. Use the JSON backup (Summary page) to move a character between servers. While creating, the full character sheet stays visible next to the wizard. Edits are shown as a difference (old → new, added / removed items) until you press **Apply** (or **Apply & next**); **Discard** drops them. @@ -61,12 +81,17 @@ Each wizard page has a **Reset this page** button that clears only that page's d ## API +All `/api/*` routes except `/api/auth/me`, `/api/auth/register` and `/api/auth/login` need the session cookie. + | Method | Path | Purpose | |---|---|---| -| GET | `/api/rules` | Rule data | -| POST | `/api/characters` | Create a character, returns `id` and `token` | -| GET | `/api/characters/{id}` | Read a character | -| PUT / DELETE | `/api/characters/{id}` | Change / delete, header `X-Edit-Token` | -| POST | `/api/presets` | Create a preset, returns `id` and `token` | -| PUT / DELETE | `/api/presets/{id}` | Change / delete, header `X-Edit-Token` | +| GET | `/api/auth/me` | Current user (or `null`) | +| POST | `/api/auth/register` / `login` / `logout` / `password` | Accounts | +| GET | `/api/rules` | Rule data including presets | +| GET / POST | `/api/characters` | Own and shared-with-me characters / create one | +| GET | `/api/characters/{id}` | Read (owner or shared users) | +| POST / DELETE | `/api/characters/{id}/shares[/{username}]` | Share / unshare (owner only) | +| PUT / DELETE | `/api/characters/{id}` | Change / delete (owner only) | +| POST | `/api/presets` | Create a preset | +| PUT / DELETE | `/api/presets/{id}` | Change / delete (owner only) | | POST | `/api/export/pdf` or `/docx` | Character JSON in the body, file in the response | diff --git a/_to_delete/awcc5.tgz b/_to_delete/awcc5.tgz deleted file mode 100644 index bcb076d..0000000 Binary files a/_to_delete/awcc5.tgz and /dev/null differ diff --git a/_to_delete/awcc6.tgz b/_to_delete/awcc6.tgz deleted file mode 100644 index d4e3af6..0000000 Binary files a/_to_delete/awcc6.tgz and /dev/null differ diff --git a/_to_delete/awcc7.tgz b/_to_delete/awcc7.tgz deleted file mode 100644 index c7539fb..0000000 Binary files a/_to_delete/awcc7.tgz and /dev/null differ diff --git a/_to_delete/awcc8.tgz b/_to_delete/awcc8.tgz deleted file mode 100644 index 38e71e9..0000000 Binary files a/_to_delete/awcc8.tgz and /dev/null differ diff --git a/_to_delete/awcc9.tgz b/_to_delete/awcc9.tgz deleted file mode 100644 index eeb6132..0000000 Binary files a/_to_delete/awcc9.tgz and /dev/null differ diff --git a/backend/app/admin.py b/backend/app/admin.py new file mode 100644 index 0000000..545841b --- /dev/null +++ b/backend/app/admin.py @@ -0,0 +1,45 @@ +"""Small admin CLI: python -m app.admin list | passwd | delete """ +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:])) diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..f43ce0d --- /dev/null +++ b/backend/app/auth.py @@ -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", "") diff --git a/backend/app/derive.py b/backend/app/derive.py index f7527c3..9faa5b4 100644 --- a/backend/app/derive.py +++ b/backend/app/derive.py @@ -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: diff --git a/backend/app/export.py b/backend/app/export.py index de9dd98..e037551 100644 --- a/backend/app/export.py +++ b/backend/app/export.py @@ -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"]: diff --git a/backend/app/main.py b/backend/app/main.py index 514d069..65dcb7e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/data/rules.json b/backend/data/rules.json index f45cabc..7530cf1 100644 --- a/backend/data/rules.json +++ b/backend/data/rules.json @@ -21,18 +21,6 @@ "talents_max": 2, "proficiency_bonus": 10, "expertise_bonus": 20, - "kit": { - "weapon_cats": [ - "melee", - "firearm" - ], - "armor_tiers": [ - "light" - ], - "resources_min": 1, - "resources_max": 2, - "tools": 1 - }, "carry_per_strength_mod": 10 }, diff --git a/docker-compose.yml b/docker-compose.yml index edb9dc2..16a1672 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,13 @@ services: build: . restart: unless-stopped ports: - - "8080:8000" # Host-Port bei Bedarf ändern, oder hinter einen Reverse Proxy hängen + - "8080:8000" # change the host port, or put the container behind a reverse proxy + environment: + # Code that new users must enter to create an account (set it in the .env file next to this file). + # Empty = registration is disabled. + AW_REGISTRATION_CODE: ${AW_REGISTRATION_CODE:-} + # Set to 1 when a reverse proxy (nginx, Caddy, Traefik) in front sets X-Forwarded-* headers. + AW_TRUST_PROXY: ${AW_TRUST_PROXY:-0} volumes: - - ./db:/db # SQLite-Datenbank (Charaktere) - - ./backend/data:/app/data:ro # rules.json: bearbeiten, Seite neu laden, fertig + - ./db:/db # SQLite database (users, characters, presets) + - ./backend/data:/app/data:ro # rules.json: edit, reload the page, done diff --git a/frontend/src/Account.jsx b/frontend/src/Account.jsx new file mode 100644 index 0000000..99fc72a --- /dev/null +++ b/frontend/src/Account.jsx @@ -0,0 +1,34 @@ +import { useState } from 'react' + +export default function Account({ user, onLogout }) { + const [f, setF] = useState({ old: '', new: '', new2: '' }) + const [msg, setMsg] = useState('') + const [err, setErr] = useState('') + const set = (k) => (e) => setF({ ...f, [k]: e.target.value }) + + const change = async (e) => { + e.preventDefault(); setMsg(''); setErr('') + if (f.new !== f.new2) return setErr('The two new passwords do not match.') + const r = await fetch('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ old: f.old, new: f.new }) }) + const data = await r.json().catch(() => ({})) + if (!r.ok) return setErr(typeof data.detail === 'string' ? data.detail : 'Could not change the password.') + setF({ old: '', new: '', new2: '' }); setMsg('Password changed. Other devices were logged out.') + } + + return ( +
+

Account

+

Logged in as {user}.

+
+

Change password

+ + + + {err &&

{err}

} + {msg &&

{msg}

} + +
+

+
+ ) +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index facb266..fe1648d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,9 @@ import Creator from './Creator.jsx' import Rules from './Rules.jsx' import Characters from './Characters.jsx' import Presets from './Presets.jsx' +import Login from './Login.jsx' +import Account from './Account.jsx' +import { claimLegacy } from './legacy.js' const useHash = () => { const [h, setH] = useState(window.location.hash.slice(1) || 'creator') @@ -16,16 +19,40 @@ const useHash = () => { export default function App() { const hash = useHash() + const [auth, setAuth] = useState(null) // null = checking, {user, registration_open} const [rules, setRules] = useState(null) const [error, setError] = useState('') // Used to load a saved character into the creator const [loadRequest, setLoadRequest] = useState(null) + const [charName, setCharName] = useState('') // name of the character being worked on, shown in the nav + // Ask the server who we are; any later 401 (session expired) sends us back to the login + useEffect(() => { + fetch('/api/auth/me').then((r) => r.json()).then(setAuth).catch(() => setError('Could not reach the server.')) + const out = () => { setAuth((a) => ({ user: null, registration_open: a?.registration_open })); setRules(null) } + window.addEventListener('aw-unauthorized', out) + return () => window.removeEventListener('aw-unauthorized', out) + }, []) + + const username = auth?.user?.username const loadRules = () => fetch('/api/rules').then((r) => r.json()).then(setRules).catch(() => setError('Could not load the rules.')) - useEffect(() => { loadRules() }, []) + useEffect(() => { + if (!username) return + claimLegacy().finally(loadRules) + }, [username]) // eslint-disable-line + + const logout = async () => { + await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {}) + window.location.hash = 'creator' + setAuth({ user: null, registration_open: auth.registration_open }); setRules(null) + } + + if (error) return

{error}

+ if (!auth) return

Loading …

+ if (!auth.user) return
setAuth({ ...auth, user: u })} />
const page = hash.split('/')[0] - const nav = [['creator', 'Create character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters']] + const nav = [['creator', charName ? `Character: ${charName}` : 'Character'], ['rules', 'Rules'], ['presets', 'Presets'], ['characters', 'My characters']] return ( <> @@ -36,19 +63,21 @@ export default function App() {
- {error &&

{error}

} - {!rules && !error &&

Loading rules …

} - {rules && page === 'creator' && setLoadRequest(null)} />} + {!rules &&

Loading rules …

} + {rules && page === 'creator' && setLoadRequest(null)} onName={setCharName} />} {rules && page === 'rules' && } {rules && page === 'presets' && } {rules && page === 'characters' && ( - { setLoadRequest(entry); window.location.hash = 'creator' }} /> + { setLoadRequest(entry); window.location.hash = 'creator' }} + onNew={() => { setLoadRequest({ new: true }); window.location.hash = 'creator' }} /> )} + {page === 'account' && }
) diff --git a/frontend/src/Characters.jsx b/frontend/src/Characters.jsx index 6eb623f..a20b164 100644 --- a/frontend/src/Characters.jsx +++ b/frontend/src/Characters.jsx @@ -1,50 +1,105 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' -const KEY = 'aw_chars' -export const loadList = () => { - try { return JSON.parse(localStorage.getItem(KEY)) || [] } catch { return [] } -} -export const saveList = (l) => { - try { localStorage.setItem(KEY, JSON.stringify(l)) } catch { /* storage blocked */ } -} -export const rememberCharacter = (id, token, name) => { - const l = loadList().filter((c) => c.id !== id) - l.unshift({ id, token, name: name || 'Nameless', updated: Date.now() }) - saveList(l) -} +function SharePanel({ c, reload }) { + const [name, setName] = useState('') + const [err, setErr] = useState('') -export default function Characters({ onOpen }) { - const [list, setList] = useState(loadList) - - const remove = async (c) => { - if (!window.confirm(`Really delete "${c.name}"?`)) return - await fetch(`/api/characters/${c.id}`, { method: 'DELETE', headers: { 'X-Edit-Token': c.token } }).catch(() => {}) - const l = list.filter((x) => x.id !== c.id) - saveList(l); setList(l) + const add = async (e) => { + e.preventDefault(); setErr('') + if (!name.trim()) return + const r = await fetch(`/api/characters/${c.id}/shares`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: name }) }) + const data = await r.json().catch(() => ({})) + if (!r.ok) return setErr(typeof data.detail === 'string' ? data.detail : 'Could not share.') + setName(''); reload() + } + const remove = async (u) => { + await fetch(`/api/characters/${c.id}/shares/${encodeURIComponent(u)}`, { method: 'DELETE' }).catch(() => {}) + reload() } + return ( +
+

These users can view this character (read-only), for example your DM:

+
+ {c.shared_with.length === 0 && Nobody yet.} + {c.shared_with.map((u) => ( + {u} + ))} +
+
+ setName(e.target.value)} placeholder="Username" aria-label="Username to share with" /> + +
+ {err &&

{err}

} +
+ ) +} + +export default function Characters({ onOpen, onNew }) { + const [data, setData] = useState(null) + const [open, setOpen] = useState(null) // id of the character whose share panel is open + const [err, setErr] = useState('') + + const load = () => fetch('/api/characters').then((r) => r.json()).then(setData).catch(() => setErr('Could not load your characters.')) + useEffect(() => { load() }, []) + + const remove = async (c) => { + if (!window.confirm(`Really delete "${c.name}"?`)) return + const r = await fetch(`/api/characters/${c.id}`, { method: 'DELETE' }).catch(() => null) + if (!r || !r.ok) return setErr('Deleting failed.') + load() + } + const when = (t) => new Date(t * 1000).toLocaleString('en-GB') + return (
-

My characters

+
+

My characters

+ +

- This list is stored in this browser. The characters themselves live on the server and can be shared by link - (#creator/<ID>), but only this browser holds the key needed to change them. + These characters are saved in your account. Only you and the users you share a character with can see it. + Shared users can look at it but not change it.

- {list.length === 0 &&

Nothing saved yet. Create a character and click "Save".

} + {err &&

{err}

} + {data && data.mine.length === 0 &&

Nothing saved yet. Use "New character", then click "Save" on the Summary page.

}
    - {list.map((c) => ( -
  • -
    - {c.name} -
    ID {c.id} · {new Date(c.updated).toLocaleString('en-GB')}
    -
    -
    - - + {(data?.mine || []).map((c) => ( +
  • +
    +
    + {c.name} +
    + ID {c.id} · {when(c.updated)}{c.shared_with.length > 0 && ` · shared with ${c.shared_with.join(', ')}`} +
    +
    +
    + + + +
    + {open === c.id && }
  • ))}
+ + {data && data.shared.length > 0 && ( + <> +

Shared with me

+
    + {data.shared.map((c) => ( +
  • +
    + {c.name} +
    from {c.owner} · {when(c.updated)}
    +
    +
    +
  • + ))} +
+ + )}
) } diff --git a/frontend/src/Creator.jsx b/frontend/src/Creator.jsx index 25fc960..f8b5850 100644 --- a/frontend/src/Creator.jsx +++ b/frontend/src/Creator.jsx @@ -1,26 +1,25 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js' -import { rememberCharacter } from './Characters.jsx' import SheetPanel from './SheetPanel.jsx' -const DRAFT = 'aw_draft' const STEPS = ['Concept', 'Attributes', 'Profession', 'Skills', 'Talents', 'Mutations', 'Equipment', 'Summary'] const SOURCE = { noxium: 'Noxium', diesium: 'Diesium', hybrid: 'Hybrid', precyber: 'Pre-meteor cybernetics' } const Badge = () => Placeholder -function readDraft() { - try { return JSON.parse(localStorage.getItem(DRAFT)) } catch { return null } +function readDraft(key) { + try { return JSON.parse(localStorage.getItem(key)) } catch { return null } } -export default function Creator({ rules: baseRules, loadRequest, clearLoad }) { +export default function Creator({ rules: baseRules, user, loadRequest, clearLoad, onName }) { + const DRAFT = `aw_draft:${user}` // drafts are kept per account const freshCharacter = () => applyPreset(emptyCharacter(baseRules), baseRules, baseRules.default_preset || 'standard') const [ch, setCh] = useState(() => { - const d = readDraft() + const d = readDraft(DRAFT) return d?.ch ? normalize(d.ch, baseRules) : freshCharacter() }) // `ch` is the working copy the pages edit; `committed` is the applied state shown as "before" in the side panel const [committed, setCommitted] = useState(() => { - const d = readDraft() + const d = readDraft(DRAFT) const src = d?.committed || d?.ch return src ? normalize(src, baseRules) : freshCharacter() }) @@ -28,7 +27,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) { const pending = JSON.stringify(ch) !== JSON.stringify(committed) const apply = () => setCommitted(ch) const discard = () => setCh(committed) - const [saved, setSaved] = useState(() => readDraft()?.saved || null) // {id, token} + const [saved, setSaved] = useState(() => readDraft(DRAFT)?.saved || null) // {id} const [step, setStep] = useState(0) const [msg, setMsg] = useState('') const fileRef = useRef(null) @@ -42,6 +41,8 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) { const set = (patch) => setCh((x) => ({ ...x, ...patch })) const cr = rules.creation + useEffect(() => { onName?.(ch.name) }, [ch.name]) // eslint-disable-line + // Keep the draft in this browser useEffect(() => { try { localStorage.setItem(DRAFT, JSON.stringify({ ch, committed, saved })) } catch { /* ignore */ } @@ -50,8 +51,17 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) { // Load a character picked in "My characters" useEffect(() => { if (!loadRequest) return + if (loadRequest.new) { // "New character" button on the My characters page + const untouched = JSON.stringify(ch) === JSON.stringify(freshCharacter()) && !saved + if (untouched || window.confirm('Start a new character? Your current draft is discarded unless you saved it.')) { + setBoth(freshCharacter()); setSaved(null); setStep(0) + } + clearLoad() + return + } fetch(`/api/characters/${loadRequest.id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }) - .then((data) => { setBoth(normalize(data, baseRules)); setSaved({ id: loadRequest.id, token: loadRequest.token }); setStep(7) }) + .then((data) => { setBoth(normalize(data, baseRules)); setSaved(loadRequest.owner ? null : { id: loadRequest.id }); setStep(7) + if (loadRequest.owner) setMsg(`This is ${loadRequest.owner}'s character. You can look at it; "Save" creates your own copy.`) }) .catch(() => setMsg('Could not load the character.')) .finally(clearLoad) }, [loadRequest]) // eslint-disable-line @@ -62,7 +72,7 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) { if (!id || loadRequest) return fetch(`/api/characters/${id}`).then((r) => { if (!r.ok) throw new Error(); return r.json() }) .then((data) => { setBoth(normalize(data, baseRules)); setSaved(null); setStep(7); setMsg('Shared character loaded. "Save" creates your own copy.') }) - .catch(() => setMsg('Shared character not found.')) + .catch(() => setMsg('Character not found, or it is not shared with you.')) }, []) // eslint-disable-line // Moving between pages: pending edits are applied first (Next applies silently, other jumps ask). @@ -89,15 +99,14 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) { const save = async () => { try { if (saved) { - const r = await fetch(`/api/characters/${saved.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'X-Edit-Token': saved.token }, body: JSON.stringify(ch) }) + const r = await fetch(`/api/characters/${saved.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) }) if (!r.ok) throw new Error() - rememberCharacter(saved.id, saved.token, ch.name) flash('Saved.') } else { const r = await fetch('/api/characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ch) }) if (!r.ok) throw new Error() - const { id, token } = await r.json() - setSaved({ id, token }); rememberCharacter(id, token, ch.name) + const { id } = await r.json() + setSaved({ id }) flash('Saved. The share link is shown below.') } } catch { flash('Saving failed.') } @@ -357,69 +366,44 @@ export default function Creator({ rules: baseRules, loadRequest, clearLoad }) { ) // --- Equipment --- - const kitRule = cr.kit const items = rules.equipment const catName = (id) => rules.equipment_categories.find((x) => x.id === id).name - const setKit = (patch) => set({ kit: { ...ch.kit, ...patch } }) - const weaponItems = items.filter((e) => kitRule.weapon_cats.includes(e.cat)) - const armorItems = items.filter((e) => e.cat === 'armor' && kitRule.armor_tiers.includes(e.tier)) - const resourceItems = items.filter((e) => e.cat === 'resource') - const toolItems = items.filter((e) => e.cat === 'tool') - - const ItemCard = ({ e, on, onClick, disabled }) => ( - - ) - const SingleSlot = ({ title, list, slot }) => ( - <> -

{title}

-
{list.map((e) => ( - setKit({ [slot]: ch.kit[slot] === e.id ? '' : e.id })} /> - ))}
- - ) const setExtra = (id, qty) => { const extras = { ...ch.kit.extras } if (qty <= 0) delete extras[id]; else extras[id] = qty - setKit({ extras }) + set({ kit: { ...ch.kit, extras } }) } const overWeight = c.weight > c.carryMax const stepGear = ( <>

Equipment

-

Starting gear: one weapon (melee or firearm), light armor or clothing, {kitRule.resources_min}-{kitRule.resources_max} resources and one tool. Everything else can be added as extra items below.

+

Pick everything your character carries. Ask your GM what is allowed. Use + and − on a tile to change the amount.

Carried: {c.weight} / {c.carryMax} kg (Strength Mod x {cr.carry_per_strength_mod}){overWeight && ' – overloaded'}
- - -

Resources ({ch.kit.resources.length}/{kitRule.resources_max})

-
{resourceItems.map((e) => { - const on = ch.kit.resources.includes(e.id) - return = kitRule.resources_max} - onClick={() => setKit({ resources: on ? ch.kit.resources.filter((x) => x !== e.id) : [...ch.kit.resources, e.id] })} /> - })}
- - -

Extra items

-

Anything else from the document's item lists. Ask your GM what you may take along.

- {rules.equipment_categories.map((cat) => ( -
- {cat.name} ({items.filter((e) => e.cat === cat.id).reduce((n, e) => n + (ch.kit.extras[e.id] || 0), 0)} chosen) -
{items.filter((e) => e.cat === cat.id).map((e) => ( -
-
{e.name}
{e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg{e.note ? ` · ${e.note}` : ''}
-
- - {ch.kit.extras[e.id] || 0} - -
-
- ))}
-
- ))} + {rules.equipment_categories.map((cat) => { + const list = items.filter((e) => e.cat === cat.id) + const n = list.reduce((sum, e) => sum + (ch.kit.extras[e.id] || 0), 0) + return ( +
+

{cat.name} {n > 0 && ({n} chosen)}

+
{list.map((e) => { + const qty = ch.kit.extras[e.id] || 0 + return ( +
0 ? 'on' : ''}`}> + {e.name} + {e.stat}{e.tier ? ` (${e.tier})` : ''} · {e.kg} kg + {e.note && {e.note}} +
+ + {qty} + +
+
+ ) + })}
+
+ ) + })}