STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
userdata.py
Go to the documentation of this file.
1"""Per-user data storage, keyed on the caller's identity.
2
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.
8
9Layout, under ``USERDATA_DIR`` (prod: a mounted volume; dev: ``<subproject>/.userdata``)::
10
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
14
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.
19
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.
24"""
25
26from __future__ import annotations
27
28import json
29import os
30import re
31import tempfile
32from datetime import datetime, timezone
33from pathlib import Path
34
35from fastapi import Request
36
37_DEV_USER = "local"
38
39# user / app / slug all become path segments, so they must never carry a
40# separator or "..". Emails are path-safe apart from case; slugs derive from
41# user-supplied names. The dot is allowed so release labels like "0.1" survive.
42# Conservative allowlist, everything else -> "-".
43_UNSAFE = re.compile(r"[^a-z0-9._@-]+")
44
45
46def _sanitize(part: str, *, fallback: str) -> str:
47 cleaned = _UNSAFE.sub("-", (part or "").strip().lower()).strip("-.")
48 # Anything that reduces to nothing or a dot-path is unusable as a segment.
49 return fallback if cleaned in ("", ".", "..") else cleaned
50
51
52def slugify(name: str) -> str:
53 """Filename-safe slug from a user-given name; ``""`` if nothing usable."""
54 return _sanitize(name, fallback="")
55
56
57def slug_user(email: str) -> str:
58 """Normalize an email into the directory segment that identifies its owner.
59
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.
64 """
65 return _sanitize(email, fallback=_DEV_USER)
66
67
69 """The on-disk layout for one app.
70
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.
73 """
74
75 def __init__(self, app: str, default_root: Path | str | None = None) -> None:
76 self.app = app
77 self._default_root = Path(default_root) if default_root else None
78
79 # ── roots and identity ───────────────────────────────────────────────────
80
81 def _root(self) -> Path:
82 env = os.environ.get("USERDATA_DIR")
83 if env:
84 return Path(env)
85 if self._default_root is None:
86 raise RuntimeError("USERDATA_DIR is unset and no default root was given")
87 return self._default_root
88
89 def current_user(self, request: Request) -> str:
90 """Whose data to use: ``X-Auth-Email`` in prod (from Caddy), else ``local``.
91
92 Never raises and never denies -- a missing header is dev, not a rejection.
93 """
94 return _sanitize(request.headers.get("X-Auth-Email", ""), fallback=_DEV_USER)
95
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.
98
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).
102 """
103 seg = app or self.app
104 d = self._root() / _sanitize(user, fallback=_DEV_USER) / _sanitize(seg, fallback=seg)
105 if create:
106 d.mkdir(parents=True, exist_ok=True)
107 return d
108
109 def site_dir(self, *, create: bool = False) -> Path:
110 """``<root>/.site/<app>``: settings that belong to the app, not a user.
111
112 Dot-prefixed so :meth:`all_users` -- which skips dot directories -- can
113 never mistake it for a person.
114 """
115 d = self._root() / ".site" / _sanitize(self.app, fallback=self.app)
116 if create:
117 d.mkdir(parents=True, exist_ok=True)
118 return d
119
120 def all_users(self, app: str | None = None) -> list[str]:
121 """Every user slug that has data for ``app``, sorted. Never creates anything.
122
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.
126 """
127 try:
128 root = self._root()
129 except RuntimeError:
130 return []
131 if not root.is_dir():
132 return []
133 seg = _sanitize(app or self.app, fallback=app or self.app)
134 return sorted(
135 p.name
136 for p in root.iterdir()
137 if p.is_dir() and not p.name.startswith(".") and (p / seg).is_dir()
138 )
139
140 # Re-exported as methods so an app's shim can bind everything from one object.
141 slugify = staticmethod(slugify)
142 slug_user = staticmethod(slug_user)
143
144 # ── the older named-config library (EngineDesign + recovery) ────────────
145 #
146 # A second, parallel per-user store that predates the versioned documents
147 # below and is not part of sharing: these blobs stay private to their owner.
148 # pid-designer never had one.
149
150 def _configs_dir(self, user: str) -> Path:
151 d = self.user_dir(user) / "configs"
152 d.mkdir(parents=True, exist_ok=True)
153 return d
154
155 def list_configs(self, user: str) -> list[dict]:
156 """Metadata for each saved config, newest first: ``{slug, name, savedAt}``."""
157 out: list[dict] = []
158 for p in self._configs_dir(user).glob("*.json"):
159 if p.name.startswith("."): # skip in-flight temp writes
160 continue
161 try:
162 blob = json.loads(p.read_text("utf-8"))
163 except (OSError, ValueError):
164 continue
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)
168 return out
169
170 def read_config(self, user: str, slug: str) -> dict | None:
171 """The full blob (``{name, savedAt, config}``) for ``slug``, or None."""
172 safe = slugify(slug)
173 if not safe:
174 return None
175 p = self._configs_dir(user) / f"{safe}.json"
176 if not p.is_file():
177 return None
178 try:
179 return json.loads(p.read_text("utf-8"))
180 except (OSError, ValueError):
181 return None
182
183 def write_config(self, user: str, name: str, config: dict) -> dict:
184 """Save/overwrite a named config atomically. Returns ``{slug, name, savedAt}``."""
185 slug = slugify(name)
186 if not slug:
187 raise ValueError("name must contain at least one letter or digit")
188 blob = {
189 "name": name.strip(),
190 "savedAt": datetime.now(timezone.utc).isoformat(),
191 "config": config,
192 }
193 d = self._configs_dir(user)
194 fd, tmp = tempfile.mkstemp(dir=d, prefix=".tmp-", suffix=".tmp")
195 try:
196 with os.fdopen(fd, "w", encoding="utf-8") as fh:
197 json.dump(blob, fh, indent=2)
198 fh.write("\n")
199 os.replace(tmp, d / f"{slug}.json")
200 except BaseException:
201 if os.path.exists(tmp):
202 os.unlink(tmp)
203 raise
204 return {"slug": slug, "name": blob["name"], "savedAt": blob["savedAt"]}
205
206 def delete_config(self, user: str, slug: str) -> bool:
207 """Remove a saved config. False if it did not exist."""
208 safe = slugify(slug)
209 if not safe:
210 return False
211 try:
212 (self._configs_dir(user) / f"{safe}.json").unlink()
213 return True
214 except OSError:
215 return False
dict|None read_config(self, str user, str slug)
Definition userdata.py:170
dict write_config(self, str user, str name, dict config)
Definition userdata.py:183
list[str] all_users(self, str|None app=None)
Definition userdata.py:120
Path site_dir(self, *, bool create=False)
Definition userdata.py:109
list[dict] list_configs(self, str user)
Definition userdata.py:155
Path user_dir(self, str user, str|None app=None, *, bool create=True)
Definition userdata.py:96
bool delete_config(self, str user, str slug)
Definition userdata.py:206
None __init__(self, str app, Path|str|None default_root=None)
Definition userdata.py:75
str current_user(self, Request request)
Definition userdata.py:89
Path _configs_dir(self, str user)
Definition userdata.py:150
str _sanitize(str part, *, str fallback)
Definition userdata.py:46
str slug_user(str email)
Definition userdata.py:57
str slugify(str name)
Definition userdata.py:52