lvwerra HF Staff commited on
Commit
c566c44
·
verified ·
1 Parent(s): b99c35d

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. app.py +85 -14
  2. static/index.html +59 -2
app.py CHANGED
@@ -29,6 +29,7 @@ import logging
29
  import os
30
  import re
31
  import secrets
 
32
  from contextlib import asynccontextmanager
33
  from datetime import datetime, timezone
34
  from pathlib import Path
@@ -83,15 +84,32 @@ async def lifespan(app: FastAPI):
83
  headers: dict[str, str] = {}
84
  if HF_TOKEN:
85
  headers["Authorization"] = f"Bearer {HF_TOKEN}"
 
 
86
  app.state.client = httpx.AsyncClient(
87
  headers=headers,
88
  timeout=httpx.Timeout(HUB_FETCH_TIMEOUT),
89
  follow_redirects=True, # Hub redirects /resolve/ → cas-bridge.xethub
 
90
  )
91
  if LOCAL_BUCKET_DIR:
92
  log.info("Local mode — reading from %s", LOCAL_BUCKET_DIR)
93
  elif HF_TOKEN:
94
  log.info("Hub mode — fetching from %s with HF_TOKEN", HUB)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  else:
96
  log.warning(
97
  "Neither LOCAL_BUCKET_DIR nor HF_TOKEN is set. /api/* will 401."
@@ -177,14 +195,28 @@ async def login(request: Request):
177
 
178
  @app.get("/auth/callback")
179
  async def oauth_callback(request: Request):
 
 
 
 
180
  error = request.query_params.get("error")
181
  if error:
 
182
  return RedirectResponse(f"/?login_error={error}")
183
  code = request.query_params.get("code")
184
  state = request.query_params.get("state")
185
- if not code or not state or state != request.session.get("oauth_state"):
 
 
 
 
 
 
 
 
186
  return RedirectResponse("/?login_error=bad_state")
187
  if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
 
188
  return RedirectResponse("/?login_error=server_unconfigured")
189
 
190
  # Use a fresh client so we don't inherit `Authorization: Bearer HF_TOKEN`
@@ -204,10 +236,11 @@ async def oauth_callback(request: Request):
204
  headers={"Accept": "application/json"},
205
  )
206
  if not token_resp.is_success:
207
- log.warning("OAuth token exchange failed: %s %s", token_resp.status_code, token_resp.text[:200])
208
  return RedirectResponse("/?login_error=token_exchange")
209
  access_token = token_resp.json().get("access_token")
210
  if not access_token:
 
211
  return RedirectResponse("/?login_error=no_token")
212
 
213
  me_resp = await oauth_client.get(
@@ -215,15 +248,18 @@ async def oauth_callback(request: Request):
215
  headers={"Authorization": f"Bearer {access_token}"},
216
  )
217
  if not me_resp.is_success:
 
218
  return RedirectResponse("/?login_error=whoami")
219
  me = me_resp.json()
220
  username = me.get("name") or me.get("preferred_username")
221
  if not username:
 
222
  return RedirectResponse("/?login_error=no_username")
223
  # Defense-in-depth org check (HF should already have rejected
224
  # non-members upstream because hf_oauth_authorized_org is set).
225
  org_names = {o.get("name") for o in (me.get("orgs") or []) if isinstance(o, dict)}
226
  if OAUTH_REQUIRED_ORG and OAUTH_REQUIRED_ORG not in org_names:
 
227
  return RedirectResponse("/?login_error=not_in_org")
228
 
229
  request.session["user"] = username
@@ -233,9 +269,10 @@ async def oauth_callback(request: Request):
233
  request.session["access_token"] = access_token
234
  request.session.pop("oauth_state", None)
235
  next_url = request.session.pop("oauth_next", "/")
 
236
  return RedirectResponse(next_url if next_url.startswith("/") else "/")
237
  except Exception as e:
238
- log.warning("OAuth callback error: %s", e)
239
  return RedirectResponse("/?login_error=exception")
240
 
241
 
@@ -312,32 +349,63 @@ async def _list_md_hub(prefix: str) -> list[dict[str, str]]:
312
  return [r for r in results if r is not None]
313
 
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  # ──────────────────────────────────────────────────────────────
316
  # /api/messages and /api/results
317
  # ──────────────────────────────────────────────────────────────
318
  @app.get("/api/messages")
319
  async def messages() -> dict[str, Any]:
320
- items = _list_md_local(PREFIX) if LOCAL_BUCKET_DIR else await _list_md_hub(PREFIX)
321
  return {"items": items, "count": len(items)}
322
 
323
 
324
  @app.get("/api/results")
325
  async def results() -> dict[str, Any]:
326
- items = (
327
- _list_md_local(RESULTS_PREFIX)
328
- if LOCAL_BUCKET_DIR
329
- else await _list_md_hub(RESULTS_PREFIX)
330
- )
331
  return {"items": items, "count": len(items)}
332
 
333
 
334
  @app.get("/api/agents")
335
  async def agents() -> dict[str, Any]:
336
- items = (
337
- _list_md_local(AGENTS_PREFIX)
338
- if LOCAL_BUCKET_DIR
339
- else await _list_md_hub(AGENTS_PREFIX)
340
- )
341
  return {"items": items, "count": len(items)}
342
 
343
 
@@ -431,6 +499,9 @@ async def post_message(post: MessagePost, request: Request) -> dict[str, Any]:
431
  except Exception as e:
432
  log.warning("Hub message write failed: %s", e)
433
  raise HTTPException(502, "Could not write message to the bucket.") from e
 
 
 
434
  return {"item": {"filename": filename, "content": content}}
435
 
436
 
 
29
  import os
30
  import re
31
  import secrets
32
+ import time
33
  from contextlib import asynccontextmanager
34
  from datetime import datetime, timezone
35
  from pathlib import Path
 
84
  headers: dict[str, str] = {}
85
  if HF_TOKEN:
86
  headers["Authorization"] = f"Bearer {HF_TOKEN}"
87
+ # Connection pool: ~100+ files fan-out per /api/messages call. Default
88
+ # max_connections=100 is borderline; bump it so we don't get queueing.
89
  app.state.client = httpx.AsyncClient(
90
  headers=headers,
91
  timeout=httpx.Timeout(HUB_FETCH_TIMEOUT),
92
  follow_redirects=True, # Hub redirects /resolve/ → cas-bridge.xethub
93
+ limits=httpx.Limits(max_connections=200, max_keepalive_connections=50),
94
  )
95
  if LOCAL_BUCKET_DIR:
96
  log.info("Local mode — reading from %s", LOCAL_BUCKET_DIR)
97
  elif HF_TOKEN:
98
  log.info("Hub mode — fetching from %s with HF_TOKEN", HUB)
99
+ # Warm the listing cache in the background so the first user request
100
+ # doesn't have to do the cold-cache fan-out (was ~10s blank page).
101
+ async def _warm_cache():
102
+ try:
103
+ await asyncio.gather(
104
+ _cached_list_md(PREFIX),
105
+ _cached_list_md(RESULTS_PREFIX),
106
+ _cached_list_md(AGENTS_PREFIX),
107
+ return_exceptions=True,
108
+ )
109
+ log.info("Cache warm-up complete.")
110
+ except Exception as e:
111
+ log.warning("Cache warm-up failed: %s", e)
112
+ asyncio.create_task(_warm_cache())
113
  else:
114
  log.warning(
115
  "Neither LOCAL_BUCKET_DIR nor HF_TOKEN is set. /api/* will 401."
 
195
 
196
  @app.get("/auth/callback")
197
  async def oauth_callback(request: Request):
198
+ # rid is logged on every branch so we can correlate one user's full flow
199
+ # in the Space logs without exposing PII. Surfaced back via header for
200
+ # browser-side correlation.
201
+ rid = secrets.token_hex(4)
202
  error = request.query_params.get("error")
203
  if error:
204
+ log.warning("[oauth %s] provider error=%s desc=%s", rid, error, request.query_params.get("error_description", "")[:200])
205
  return RedirectResponse(f"/?login_error={error}")
206
  code = request.query_params.get("code")
207
  state = request.query_params.get("state")
208
+ session_state = request.session.get("oauth_state")
209
+ if not code or not state or state != session_state:
210
+ # The single most common failure mode in iframe deployments: the
211
+ # session cookie set by /login didn't make it back to /auth/callback,
212
+ # so the saved state is missing. Log enough to tell which it is.
213
+ log.warning(
214
+ "[oauth %s] bad_state code=%s state_param=%s session_state=%s cookies_present=%s",
215
+ rid, bool(code), bool(state), bool(session_state), bool(request.cookies),
216
+ )
217
  return RedirectResponse("/?login_error=bad_state")
218
  if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
219
+ log.warning("[oauth %s] server_unconfigured", rid)
220
  return RedirectResponse("/?login_error=server_unconfigured")
221
 
222
  # Use a fresh client so we don't inherit `Authorization: Bearer HF_TOKEN`
 
236
  headers={"Accept": "application/json"},
237
  )
238
  if not token_resp.is_success:
239
+ log.warning("[oauth %s] token_exchange status=%s body=%s", rid, token_resp.status_code, token_resp.text[:300])
240
  return RedirectResponse("/?login_error=token_exchange")
241
  access_token = token_resp.json().get("access_token")
242
  if not access_token:
243
+ log.warning("[oauth %s] no_token body=%s", rid, token_resp.text[:200])
244
  return RedirectResponse("/?login_error=no_token")
245
 
246
  me_resp = await oauth_client.get(
 
248
  headers={"Authorization": f"Bearer {access_token}"},
249
  )
250
  if not me_resp.is_success:
251
+ log.warning("[oauth %s] whoami status=%s body=%s", rid, me_resp.status_code, me_resp.text[:200])
252
  return RedirectResponse("/?login_error=whoami")
253
  me = me_resp.json()
254
  username = me.get("name") or me.get("preferred_username")
255
  if not username:
256
+ log.warning("[oauth %s] no_username keys=%s", rid, sorted(me.keys()))
257
  return RedirectResponse("/?login_error=no_username")
258
  # Defense-in-depth org check (HF should already have rejected
259
  # non-members upstream because hf_oauth_authorized_org is set).
260
  org_names = {o.get("name") for o in (me.get("orgs") or []) if isinstance(o, dict)}
261
  if OAUTH_REQUIRED_ORG and OAUTH_REQUIRED_ORG not in org_names:
262
+ log.warning("[oauth %s] not_in_org user=%s orgs=%s", rid, username, sorted(org_names))
263
  return RedirectResponse("/?login_error=not_in_org")
264
 
265
  request.session["user"] = username
 
269
  request.session["access_token"] = access_token
270
  request.session.pop("oauth_state", None)
271
  next_url = request.session.pop("oauth_next", "/")
272
+ log.info("[oauth %s] success user=%s", rid, username)
273
  return RedirectResponse(next_url if next_url.startswith("/") else "/")
274
  except Exception as e:
275
+ log.warning("[oauth %s] exception %s: %s", rid, type(e).__name__, e)
276
  return RedirectResponse("/?login_error=exception")
277
 
278
 
 
349
  return [r for r in results if r is not None]
350
 
351
 
352
+ # ──────────────────────────────────────────────────────────────
353
+ # Listing cache
354
+ #
355
+ # Each /api/messages call fans out ~100+ HTTP GETs to the Hub, which costs
356
+ # ~10s on a cold CDN cache. The frontend polls every 30s and multiple users
357
+ # may be open at once, so we put a short in-process TTL cache in front of
358
+ # every listing endpoint. Single-flight via per-prefix asyncio.Lock prevents
359
+ # the thundering herd of concurrent first-time loads from each issuing
360
+ # their own fan-out.
361
+ # ──────────────────────────────────────────────────────────────
362
+ LIST_CACHE_TTL = float(os.environ.get("LIST_CACHE_TTL", "20.0"))
363
+ _list_cache: dict[str, tuple[float, list[dict[str, str]]]] = {}
364
+ _list_locks: dict[str, asyncio.Lock] = {}
365
+
366
+
367
+ async def _cached_list_md(prefix: str) -> list[dict[str, str]]:
368
+ if LOCAL_BUCKET_DIR:
369
+ # Filesystem reads are instant; no cache needed.
370
+ return _list_md_local(prefix)
371
+ now = time.monotonic()
372
+ cached = _list_cache.get(prefix)
373
+ if cached and (now - cached[0]) < LIST_CACHE_TTL:
374
+ return cached[1]
375
+ lock = _list_locks.setdefault(prefix, asyncio.Lock())
376
+ async with lock:
377
+ # Re-check under the lock — another coroutine may have refreshed
378
+ # the cache while we were waiting.
379
+ cached = _list_cache.get(prefix)
380
+ if cached and (time.monotonic() - cached[0]) < LIST_CACHE_TTL:
381
+ return cached[1]
382
+ items = await _list_md_hub(prefix)
383
+ _list_cache[prefix] = (time.monotonic(), items)
384
+ return items
385
+
386
+
387
+ def _invalidate_list_cache(prefix: str) -> None:
388
+ _list_cache.pop(prefix, None)
389
+
390
+
391
  # ──────────────────────────────────────────────────────────────
392
  # /api/messages and /api/results
393
  # ──────────────────────────────────────────────────────────────
394
  @app.get("/api/messages")
395
  async def messages() -> dict[str, Any]:
396
+ items = await _cached_list_md(PREFIX)
397
  return {"items": items, "count": len(items)}
398
 
399
 
400
  @app.get("/api/results")
401
  async def results() -> dict[str, Any]:
402
+ items = await _cached_list_md(RESULTS_PREFIX)
 
 
 
 
403
  return {"items": items, "count": len(items)}
404
 
405
 
406
  @app.get("/api/agents")
407
  async def agents() -> dict[str, Any]:
408
+ items = await _cached_list_md(AGENTS_PREFIX)
 
 
 
 
409
  return {"items": items, "count": len(items)}
410
 
411
 
 
499
  except Exception as e:
500
  log.warning("Hub message write failed: %s", e)
501
  raise HTTPException(502, "Could not write message to the bucket.") from e
502
+ # Bust the cache so other users see this message on their next poll
503
+ # rather than waiting for the TTL.
504
+ _invalidate_list_cache(PREFIX)
505
  return {"item": {"filename": filename, "content": content}}
506
 
507
 
static/index.html CHANGED
@@ -1685,6 +1685,32 @@ refreshBtn.addEventListener('click', async () => {
1685
  let postingMessage = false;
1686
  let me = { logged_in: false }; // populated by /api/me on init
1687
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1688
  function setComposerStatus(html = '', isError = false) {
1689
  composerStatus.innerHTML = html;
1690
  composerStatus.classList.toggle('error', isError);
@@ -1698,7 +1724,16 @@ function syncComposerState() {
1698
  sendBtn.classList.add('login');
1699
  sendBtn.textContent = 'Log in to post a message';
1700
  humanMessageInput.disabled = true;
1701
- setComposerStatus('Sign in with Hugging Face — only members of <strong>ml-intern-explorers</strong> can post.');
 
 
 
 
 
 
 
 
 
1702
  return;
1703
  }
1704
  sendBtn.classList.remove('login');
@@ -1724,11 +1759,33 @@ async function refreshMe() {
1724
  humanMessageInput.addEventListener('input', syncComposerState);
1725
  clearQuoteBtn.addEventListener('click', clearPendingQuote);
1726
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1727
  messageComposer.addEventListener('submit', async e => {
1728
  e.preventDefault();
1729
  if (!me.logged_in) {
1730
  // Treat the button as a login CTA when logged out.
1731
- window.location.href = '/login';
 
1732
  return;
1733
  }
1734
  const body = humanMessageInput.value.trim();
 
1685
  let postingMessage = false;
1686
  let me = { logged_in: false }; // populated by /api/me on init
1687
 
1688
+ // Surface OAuth callback errors. /auth/callback redirects to /?login_error=X
1689
+ // when something fails. We pull it out on boot, clean the URL, and show it
1690
+ // in the composer status until the user attempts another login.
1691
+ const LOGIN_ERROR_HINTS = {
1692
+ bad_state: 'session cookie was lost between /login and /auth/callback (often Safari/iframe third-party cookie blocking)',
1693
+ token_exchange: 'HF rejected the OAuth code exchange',
1694
+ no_token: 'HF returned no access_token',
1695
+ whoami: 'could not fetch your HF profile after login',
1696
+ no_username: 'HF profile had no username',
1697
+ not_in_org: 'your account is not a member of ml-intern-explorers',
1698
+ exception: 'unexpected server error during login',
1699
+ server_unconfigured: 'OAuth is not configured on this Space',
1700
+ access_denied: 'you cancelled the authorization screen',
1701
+ };
1702
+ let lastLoginError = '';
1703
+ (() => {
1704
+ const params = new URLSearchParams(window.location.search);
1705
+ const err = params.get('login_error');
1706
+ if (err) {
1707
+ lastLoginError = err;
1708
+ params.delete('login_error');
1709
+ const qs = params.toString();
1710
+ history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash);
1711
+ }
1712
+ })();
1713
+
1714
  function setComposerStatus(html = '', isError = false) {
1715
  composerStatus.innerHTML = html;
1716
  composerStatus.classList.toggle('error', isError);
 
1724
  sendBtn.classList.add('login');
1725
  sendBtn.textContent = 'Log in to post a message';
1726
  humanMessageInput.disabled = true;
1727
+ if (lastLoginError) {
1728
+ const hint = LOGIN_ERROR_HINTS[lastLoginError] || lastLoginError;
1729
+ setComposerStatus(
1730
+ `<strong>Login failed:</strong> ${escapeHtml(hint)}. ` +
1731
+ `Try again, or open the dashboard directly at <a href="${window.location.origin}" target="_top">${escapeHtml(window.location.host)}</a>.`,
1732
+ true,
1733
+ );
1734
+ } else {
1735
+ setComposerStatus('Sign in with Hugging Face — only members of <strong>ml-intern-explorers</strong> can post.');
1736
+ }
1737
  return;
1738
  }
1739
  sendBtn.classList.remove('login');
 
1759
  humanMessageInput.addEventListener('input', syncComposerState);
1760
  clearQuoteBtn.addEventListener('click', clearPendingQuote);
1761
 
1762
+ // When the dashboard runs inside the huggingface.co/spaces/... iframe, the
1763
+ // Space cookies are "third-party". Modern browsers (Safari ITP especially)
1764
+ // drop those cookies, breaking session-based auth. Navigating the *top*
1765
+ // frame to /login means the entire OAuth round-trip happens at *.hf.space
1766
+ // as a first-party context, so the session cookie sticks for everyone.
1767
+ function startLogin() {
1768
+ const loginUrl = window.location.origin + '/login';
1769
+ if (window.self !== window.top) {
1770
+ // Cross-origin parents allow child frames to *write* top.location.href
1771
+ // (the read is what's blocked), so this works even from inside HF's
1772
+ // iframe. After OAuth, the user lands at *.hf.space top-level.
1773
+ try {
1774
+ window.top.location.href = loginUrl;
1775
+ return;
1776
+ } catch {
1777
+ // Fall through to same-frame nav if the parent has unusual policies.
1778
+ }
1779
+ }
1780
+ window.location.href = loginUrl;
1781
+ }
1782
+
1783
  messageComposer.addEventListener('submit', async e => {
1784
  e.preventDefault();
1785
  if (!me.logged_in) {
1786
  // Treat the button as a login CTA when logged out.
1787
+ lastLoginError = ''; // user is retrying; clear any stale banner
1788
+ startLogin();
1789
  return;
1790
  }
1791
  const body = humanMessageInput.value.trim();