1"""Per-user data storage, keyed on the caller's identity.
3Identity comes from the ``X-Auth-Email`` header Caddy injects in production. The
4app never gates on it -- Caddy is the gate; this only decides *whose* folder to
5read and write. With no Caddy in front (local dev) there is no header, so the
6user falls back to ``local`` and everything lands in a gitignored ``.userdata/``
7beside the app. A missing header is never a rejection.
9Layout, under ``USERDATA_DIR`` (prod: a mounted volume; dev: ``<subproject>/.userdata``)::
11 <user>/<app>/index.json -- the user's design list
12 <user>/<app>/<doc-id>/current.json
13 <user>/<app>/configs/<slug>.json -- the older named-config library
15Designs are shared across users (see :mod:`stardesign.documents`): every design
16tool mounts one volume, so "list another user's designs, then copy one" is just
17reading sibling ``<user>/`` folders -- which is what this layout was chosen for.
18Nothing here gates access; that decision lives in the router.
20One :class:`UserData` per app binds the ``<app>`` segment, so callers never pass
21it. Each app's ``backend/userdata.py`` is a few lines binding this and
22re-exporting the methods under their original names, which is what lets every
23existing ``userdata.current_user(request)`` call site stay exactly as it was.
26from __future__
import annotations
32from datetime
import datetime, timezone
33from pathlib
import Path
35from fastapi
import Request
43_UNSAFE = re.compile(
r"[^a-z0-9._@-]+")
47 cleaned = _UNSAFE.sub(
"-", (part
or "").strip().lower()).strip(
"-.")
49 return fallback
if cleaned
in (
"",
".",
"..")
else cleaned
53 """Filename-safe slug from a user-given name; ``""`` if nothing usable."""
58 """Normalize an email into the directory segment that identifies its owner.
60 Exactly the transform :meth:`UserData.current_user` applies to the header, so
61 an ``?owner=`` query param and the injected identity compare without any
62 special-casing -- which is what keeps an ownership check from hinging on two
63 slightly different normalizations.
65 return _sanitize(email, fallback=_DEV_USER)
69 """The on-disk layout for one app.
71 ``default_root`` is where dev data lands when ``USERDATA_DIR`` is unset: a
72 gitignored directory beside the app, so ``./dev.sh`` needs no setup.
75 def __init__(self, app: str, default_root: Path | str |
None =
None) ->
None:
82 env = os.environ.get(
"USERDATA_DIR")
86 raise RuntimeError(
"USERDATA_DIR is unset and no default root was given")
90 """Whose data to use: ``X-Auth-Email`` in prod (from Caddy), else ``local``.
92 Never raises and never denies -- a missing header is dev, not a rejection.
94 return _sanitize(request.headers.get(
"X-Auth-Email",
""), fallback=_DEV_USER)
96 def user_dir(self, user: str, app: str |
None =
None, *, create: bool =
True) -> Path:
97 """``<root>/<user>/<app>``, created on demand. Root is not assumed to exist.
99 Pass ``create=False`` when merely *inspecting* another user's tree (the
100 cross-user browse walks every sibling folder, and must not conjure an
101 empty directory for each one it looks at).
103 seg = app
or self.
app
106 d.mkdir(parents=
True, exist_ok=
True)
109 def site_dir(self, *, create: bool =
False) -> Path:
110 """``<root>/.site/<app>``: settings that belong to the app, not a user.
112 Dot-prefixed so :meth:`all_users` -- which skips dot directories -- can
113 never mistake it for a person.
117 d.mkdir(parents=
True, exist_ok=
True)
120 def all_users(self, app: str |
None =
None) -> list[str]:
121 """Every user slug that has data for ``app``, sorted. Never creates anything.
123 All the design tools mount one shared volume, so the root holds the whole
124 team; requiring a ``<user>/<app>`` subdirectory narrows it to people who
125 have actually used *this* app.
131 if not root.is_dir():
136 for p
in root.iterdir()
137 if p.is_dir()
and not p.name.startswith(
".")
and (p / seg).is_dir()
141 slugify = staticmethod(slugify)
142 slug_user = staticmethod(slug_user)
152 d.mkdir(parents=
True, exist_ok=
True)
156 """Metadata for each saved config, newest first: ``{slug, name, savedAt}``."""
159 if p.name.startswith(
"."):
162 blob = json.loads(p.read_text(
"utf-8"))
163 except (OSError, ValueError):
165 out.append({
"slug": p.stem,
"name": blob.get(
"name", p.stem),
166 "savedAt": blob.get(
"savedAt")})
167 out.sort(key=
lambda c: c.get(
"savedAt")
or "", reverse=
True)
171 """The full blob (``{name, savedAt, config}``) for ``slug``, or None."""
179 return json.loads(p.read_text(
"utf-8"))
180 except (OSError, ValueError):
184 """Save/overwrite a named config atomically. Returns ``{slug, name, savedAt}``."""
187 raise ValueError(
"name must contain at least one letter or digit")
189 "name": name.strip(),
190 "savedAt": datetime.now(timezone.utc).isoformat(),
194 fd, tmp = tempfile.mkstemp(dir=d, prefix=
".tmp-", suffix=
".tmp")
196 with os.fdopen(fd,
"w", encoding=
"utf-8")
as fh:
197 json.dump(blob, fh, indent=2)
199 os.replace(tmp, d / f
"{slug}.json")
200 except BaseException:
201 if os.path.exists(tmp):
204 return {
"slug": slug,
"name": blob[
"name"],
"savedAt": blob[
"savedAt"]}
207 """Remove a saved config. False if it did not exist."""
dict|None read_config(self, str user, str slug)
dict write_config(self, str user, str name, dict config)
list[str] all_users(self, str|None app=None)
Path site_dir(self, *, bool create=False)
list[dict] list_configs(self, str user)
Path user_dir(self, str user, str|None app=None, *, bool create=True)
bool delete_config(self, str user, str slug)
None __init__(self, str app, Path|str|None default_root=None)
str current_user(self, Request request)
Path _configs_dir(self, str user)
str _sanitize(str part, *, str fallback)