1"""Who a design can be shared with: the team roster.
3Sharing needs a list of people, and the design tools have never had one -- they
4only ever knew the single email in the request header. The roster here is the
5**union** of two sources, and it needs both:
7* **The auth service** (``AUTH_USERS_URL`` -> ``auth/main.py``'s ``/users``),
8 which records everyone who has signed in. It is the only source of real
9 display names; nothing on this side of the wire knows them.
10* **The shared userdata volume** -- everyone who already has designs. Auth only
11 learns about a person when they log in and sessions last 30 days, so for the
12 first month after this ships the auth roster is missing most of the team. In
13 dev there is no auth service at all. The volume scan covers both cases, which
14 is what keeps ``./dev.sh`` working with nothing in front of it.
16Auth being unset, down, or slow degrades this to the volume scan. It must never
17break listing or editing designs.
19**On making this app an authenticated-request forwarder.** The outbound call
20carries exactly one header -- the caller's own session cookie, which Caddy has
21already handed us -- and its URL comes from ``AUTH_USERS_URL`` in the
22environment, never from the request. A caller can therefore influence *whether*
23we ask auth, never *whom* we ask, which is what keeps this from being an SSRF
24hole. Keep it that way: no request-supplied URLs, no header pass-through.
27from __future__
import annotations
36from fastapi
import Request
42TTL_SECONDS = float(os.environ.get(
"AUTH_USERS_TTL",
"60"))
47_lock = threading.Lock()
54_cache: tuple[float, list[dict]] |
None =
None
58 """The auth service's roster, or ``[]`` if unset, unreachable, or garbage."""
59 base = os.environ.get(
"AUTH_USERS_URL",
"").strip().rstrip(
"/")
62 req = urllib.request.Request(f
"{base}/users", method=
"GET")
71 req.add_header(
"User-Agent",
"stardesign/1.0")
73 req.add_header(
"Cookie", cookie)
75 with urllib.request.urlopen(req, timeout=TIMEOUT_SECONDS)
as resp:
76 data = json.loads(resp.read().
decode(
"utf-8"))
77 except (urllib.error.URLError, OSError, ValueError, TimeoutError):
79 if not isinstance(data, list):
82 {
"email": str(row[
"email"]),
"name": str(row.get(
"name")
or "")}
84 if isinstance(row, dict)
and row.get(
"email")
89 """:func:`_fetch_auth` behind a process-wide TTL cache.
91 Cached across callers, not per-cookie: every session that reaches this app
92 has already been verified by Caddy and would get the same answer. A failed
93 fetch caches ``[]`` for the same TTL, so an auth outage costs one attempt a
94 minute rather than one per request.
96 now = time.monotonic()
99 if cached
is not None and now - cached[0] < TTL_SECONDS:
101 fetched =
_fetch_auth(request.headers.get(
"cookie",
""))
103 globals()[
"_cache"] = (time.monotonic(), fetched)
107def roster(request: Request, ud: UserData) -> list[dict]:
108 """Everyone a design can be shared with: ``[{email, name}]``, sorted.
110 Keyed on the path slug, so the same person arriving from both sources
111 collapses to one row -- and auth's spelling of the address wins, since the
112 volume only ever holds the slug.
114 people: dict[str, dict] = {
115 user: {
"email": user,
"name":
""}
for user
in ud.all_users()
118 slug = slug_user(row[
"email"])
119 entry = people.setdefault(slug, {
"email": row[
"email"],
"name":
""})
120 entry[
"email"] = row[
"email"]
121 entry[
"name"] = row[
"name"]
or entry[
"name"]
122 return sorted(people.values(), key=
lambda u: (u[
"name"].lower()
or u[
"email"], u[
"email"]))
126 """``slug -> display name`` for labelling design owners; ``""`` when unknown."""
127 return {slug_user(u[
"email"]): u[
"name"]
for u
in roster(request, ud)}
list[dict] _auth_roster(Request request)
list[dict] _fetch_auth(str cookie)
dict[str, str] display_names(Request request, UserData ud)
list[dict] roster(Request request, UserData ud)
static void decode(const disk_t *d)