File size: 4,361 Bytes
c6815eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ddc2d41
 
 
 
 
c6815eb
ddc2d41
 
 
c6815eb
ddc2d41
 
c6815eb
 
ddc2d41
 
c6815eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ddc2d41
 
 
c6815eb
ddc2d41
 
 
c6815eb
 
 
 
 
ddc2d41
c6815eb
ddc2d41
 
 
 
c6815eb
 
ddc2d41
 
 
c6815eb
 
 
ddc2d41
c6815eb
ddc2d41
c6815eb
 
 
 
ddc2d41
 
 
 
c6815eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
"""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 `<DATA_DIR>/users/<uid>.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())