"""Durable per-user storage for FrogQuest — one JSON file per user. Why JSON files, not SQLite: HF retired the classic Persistent Storage disk; the in-Space option is now a mounted **Storage Bucket** (S3-like object storage). Buckets do whole-file writes only — no byte-range writes or POSIX locks — which breaks SQLite but is a perfect match for our access pattern: load/save ONE state blob per user. So each user gets `/users/.json` holding the entire app state (photo, theme, adventure, quests, campaigns, generated images, selected id) — the same dict gr.BrowserState holds. Identity: no login. The uid is generated client-side (app.py boot), lives in BrowserState, and is shown to the user as their "Hero Code" so they can reclaim their data on a new/wiped browser. DATA_DIR resolution (first hit wins): 1. FROGQUEST_DATA_DIR env var (explicit override) 2. /data — mount your Storage Bucket here (Space Settings -> Storage Buckets -> Mount a bucket -> path /data, read-write) 3. ./.frogquest — local dev fallback (gitignored) """ from __future__ import annotations import json import os import re import threading _lock = threading.Lock() _data_dir: str | None = None def _resolve_data_dir() -> str: override = os.environ.get("FROGQUEST_DATA_DIR") if override: return override if os.path.isdir("/data") and os.access("/data", os.W_OK): # mounted Storage Bucket return "/data" return os.path.join(os.path.dirname(os.path.abspath(__file__)), ".frogquest") # local dev def _users_dir() -> str: global _data_dir if _data_dir is None: _data_dir = _resolve_data_dir() d = os.path.join(_data_dir, "users") os.makedirs(d, exist_ok=True) return d def _safe_uid(uid: str) -> str | None: """The uid comes from the client (BrowserState / pasted Hero Code) — sanitize before it touches a filesystem path. Returns None if nothing safe remains.""" cleaned = re.sub(r"[^a-zA-Z0-9_-]", "", str(uid or ""))[:64] return cleaned or None def _path_for(uid: str) -> str | None: safe = _safe_uid(uid) return os.path.join(_users_dir(), f"{safe}.json") if safe else None def load(uid: str | None) -> dict | None: """Return the saved state dict for this user, or None (no uid / no file / unreadable).""" if not uid: return None try: path = _path_for(uid) if not path or not os.path.isfile(path): return None with _lock, open(path, "r", encoding="utf-8") as f: data = json.load(f) return data if isinstance(data, dict) else None except (OSError, json.JSONDecodeError): return None def save(uid: str | None, state: dict) -> None: """Write the full state dict for this user. Atomic (tmp + os.replace) so a crash mid-write never leaves a half-written file. No-op without a uid; never raises into the request path.""" if not uid or not isinstance(state, dict): return try: path = _path_for(uid) if not path: return blob = json.dumps(state, ensure_ascii=False) tmp = path + ".tmp" with _lock: with open(tmp, "w", encoding="utf-8") as f: f.write(blob) os.replace(tmp, path) except (OSError, TypeError, ValueError) as e: print(f"[store] save failed for uid={uid!r}: {e}") if __name__ == "__main__": # quick self-test (no model, no network) import tempfile with tempfile.TemporaryDirectory() as td: os.environ["FROGQUEST_DATA_DIR"] = td _data_dir = None # re-resolve with the override assert load(None) is None assert load("nobody") is None sample = {"theme": "fantasy", "quests": [{"id": "q1"}], "images": {"q1": {"initial": "data:..."}}, "campaigns": []} save("hero-123", sample) assert load("hero-123") == sample sample["quests"].append({"id": "q2"}) save("hero-123", sample) assert len(load("hero-123")["quests"]) == 2 save("../../evil", sample) # path traversal attempt -> sanitized, stays inside users/ assert os.path.isfile(os.path.join(td, "users", "evil.json")) assert load("../../evil") == sample print("store.py self-test OK ->", _users_dir())