Revalidate the current HF account before displaying private history
Browse filesPerform fresh PKCE/OIDC checks before entry and after leaving the page, bind completed checks to partitioned cookies, clear old account content, and reject stale-tab metadata requests after account changes.
- hf_space/history_bridge.py +7 -0
- hf_space/history_oauth.py +64 -12
- hf_space/history_routes.py +12 -2
- hf_space/history_ui.css +1 -0
- hf_space/history_ui.js +76 -28
- tests/test_history_account_switch.py +164 -0
- tests/test_history_ui_resume.cjs +86 -5
hf_space/history_bridge.py
CHANGED
|
@@ -211,6 +211,12 @@ class AppSessions:
|
|
| 211 |
self._revoked = {key: expiry for key, expiry in self._revoked.items() if expiry > now}
|
| 212 |
self._revoked[claims['sid']] = claims.get('exp', now)
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
class BootstrapGuards:
|
| 216 |
"""First-party proof that browser metadata approved the popup navigation."""
|
|
@@ -264,6 +270,7 @@ class AppSessionMiddleware:
|
|
| 264 |
if cookie is not None:
|
| 265 |
identity, claims = self.sessions.decode(cookie) if len(raw_values) <= 1 else (None, None)
|
| 266 |
scope['supra_history_app_identity'] = identity
|
|
|
|
| 267 |
logout = scope.get('path', '').rstrip('/') == '/logout'
|
| 268 |
if logout:
|
| 269 |
self.sessions.revoke(claims)
|
|
|
|
| 211 |
self._revoked = {key: expiry for key, expiry in self._revoked.items() if expiry > now}
|
| 212 |
self._revoked[claims['sid']] = claims.get('exp', now)
|
| 213 |
|
| 214 |
+
@staticmethod
|
| 215 |
+
def clear_cookie(response):
|
| 216 |
+
for suffix in ('; Partitioned', ''):
|
| 217 |
+
value = f'{COOKIE_NAME}=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Secure; SameSite=None{suffix}'
|
| 218 |
+
response.headers.append('set-cookie', value)
|
| 219 |
+
|
| 220 |
|
| 221 |
class BootstrapGuards:
|
| 222 |
"""First-party proof that browser metadata approved the popup navigation."""
|
|
|
|
| 270 |
if cookie is not None:
|
| 271 |
identity, claims = self.sessions.decode(cookie) if len(raw_values) <= 1 else (None, None)
|
| 272 |
scope['supra_history_app_identity'] = identity
|
| 273 |
+
scope['supra_history_app_claims'] = claims if identity else None
|
| 274 |
logout = scope.get('path', '').rstrip('/') == '/logout'
|
| 275 |
if logout:
|
| 276 |
self.sessions.revoke(claims)
|
hf_space/history_oauth.py
CHANGED
|
@@ -6,12 +6,13 @@ from dataclasses import dataclass
|
|
| 6 |
import hmac
|
| 7 |
import json
|
| 8 |
import os
|
|
|
|
| 9 |
import secrets
|
| 10 |
import time
|
| 11 |
from urllib.parse import urlsplit
|
| 12 |
|
| 13 |
from fastapi import FastAPI, HTTPException, Request
|
| 14 |
-
from itsdangerous import BadSignature, TimestampSigner
|
| 15 |
from starlette.middleware.sessions import SessionMiddleware
|
| 16 |
from starlette.requests import HTTPConnection
|
| 17 |
from starlette.responses import HTMLResponse, JSONResponse
|
|
@@ -25,6 +26,7 @@ AUTH_PATH = '/api/history/auth'
|
|
| 25 |
FLOW_KEY = '_supra_history_oauth_flow'
|
| 26 |
ATTEMPT_KEY = '_supra_history_oauth_silent_at'
|
| 27 |
FLOW_LIFETIME = 600
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
@dataclass(frozen=True)
|
|
@@ -127,11 +129,14 @@ def _completion(settings: OAuthSettings, request: Request, error: str | None = N
|
|
| 127 |
<style nonce="{nonce}">body{{background:#101010;color:#ddd;font:16px Arial,sans-serif;padding:32px}}a{{color:#f2cd70}}</style></head>
|
| 128 |
<body><p>{'Account connected. You can close this window and return to Supra-Tex.' if authenticated else 'You can close this window and return to Supra-Tex.'}</p>
|
| 129 |
<script nonce="{nonce}">(() => {{const message={payload};const origin={origin};if(window.parent!==window)window.parent.postMessage(message,origin);if(window.opener)window.opener.postMessage(message,origin);window.close();}})();</script></body></html>'''
|
| 130 |
-
|
| 131 |
'Cache-Control': 'private, no-store', 'Pragma': 'no-cache',
|
| 132 |
'Content-Security-Policy': f"default-src 'none'; script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'; base-uri 'none'; form-action 'none'; frame-ancestors 'self' https://huggingface.co",
|
| 133 |
'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff',
|
| 134 |
})
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
|
| 137 |
def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None, oauth_client=None,
|
|
@@ -153,12 +158,13 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
|
|
| 153 |
install_session_write_guard(app)
|
| 154 |
bridges = bridges if bridges is not None else Bridges()
|
| 155 |
sessions = sessions if sessions is not None else AppSessions(settings.client_secret)
|
|
|
|
| 156 |
guards = BootstrapGuards(settings.client_secret)
|
| 157 |
app.add_middleware(AppSessionMiddleware, sessions=sessions)
|
| 158 |
app.state.supra_history_bridges = bridges
|
| 159 |
app.state.supra_history_app_sessions = sessions
|
| 160 |
|
| 161 |
-
async def authorize(request: Request, silent=False, bridge_id=None):
|
| 162 |
# Older Gradio sessions include a full ID token/profile. Once invalid,
|
| 163 |
# retaining them alongside PKCE state can overflow the browser's 4 KiB
|
| 164 |
# cookie limit and lose the new authorization state.
|
|
@@ -169,7 +175,7 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
|
|
| 169 |
previous_attempt = float(request.session.get(ATTEMPT_KEY, 0))
|
| 170 |
except (ValueError, TypeError):
|
| 171 |
previous_attempt = now
|
| 172 |
-
if now - previous_attempt < 30:
|
| 173 |
return _completion(settings, request, 'unavailable')
|
| 174 |
request.session[ATTEMPT_KEY] = now
|
| 175 |
state = secrets.token_urlsafe(32)
|
|
@@ -178,6 +184,8 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
|
|
| 178 |
if name.startswith('_state_supra_history_'):
|
| 179 |
request.session.pop(name, None)
|
| 180 |
request.session[FLOW_KEY] = {'state': state, 'created_at': now, 'silent': silent,
|
|
|
|
|
|
|
| 181 |
**({'bridge_id': bridge_id} if bridge_id else {})}
|
| 182 |
params = {'state': state, 'nonce': nonce}
|
| 183 |
if silent:
|
|
@@ -195,10 +203,31 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
|
|
| 195 |
return _completion(settings, request, 'unavailable')
|
| 196 |
|
| 197 |
@app.get(AUTH_PATH + '/start')
|
| 198 |
-
async def start(request: Request, silent: bool = False):
|
| 199 |
-
if optional_identity(request) is not None:
|
| 200 |
return _completion(settings, request)
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
@app.get(AUTH_PATH + '/popup')
|
| 204 |
async def popup(request: Request):
|
|
@@ -249,6 +278,17 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
|
|
| 249 |
async def callback(request: Request):
|
| 250 |
state = request.query_params.get('state', '')
|
| 251 |
flow = request.session.get(FLOW_KEY)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
valid = isinstance(flow, dict) and isinstance(flow.get('state'), str)
|
| 253 |
if valid:
|
| 254 |
try:
|
|
@@ -260,11 +300,13 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
|
|
| 260 |
# here first, including denied/failed callbacks, and preserve newer flows.
|
| 261 |
if not valid:
|
| 262 |
return _completion(settings, request, 'unavailable')
|
|
|
|
| 263 |
bridge_id = flow.get('bridge_id')
|
|
|
|
| 264 |
try:
|
| 265 |
token = await oauth_client.authorize_access_token(request)
|
| 266 |
if not isinstance(token, dict) or not token.get('id_token') or not isinstance(token.get('userinfo'), dict):
|
| 267 |
-
return _completion(settings, request, 'unavailable', connected=False if
|
| 268 |
profile = token['userinfo']
|
| 269 |
verified = {key: profile[key] for key in ('sub', 'preferred_username', 'name', 'picture', 'profile') if key in profile}
|
| 270 |
candidate = {'access_token': token.get('access_token'), 'expires_at': token.get('expires_at'),
|
|
@@ -280,7 +322,7 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
|
|
| 280 |
request.session.pop('oauth_info', None)
|
| 281 |
else:
|
| 282 |
request.session['oauth_info'] = old
|
| 283 |
-
return _completion(settings, request, 'unavailable', connected=False if
|
| 284 |
if bridge_id:
|
| 285 |
try:
|
| 286 |
expires = min(float(candidate['expires_at']), float(profile['exp']))
|
|
@@ -288,14 +330,24 @@ def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None,
|
|
| 288 |
bridges.finish(bridge_id)
|
| 289 |
return _completion(settings, request, 'unavailable', connected=False)
|
| 290 |
claims = sessions.claims(identity, expires)
|
|
|
|
| 291 |
if sessions.validate(claims) is None or not bridges.finish(bridge_id, claims):
|
| 292 |
return _completion(settings, request, 'unavailable', connected=False)
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
except OAuthError as exc:
|
| 295 |
return _completion(settings, request, getattr(exc, 'error', 'unavailable'),
|
| 296 |
-
connected=False if
|
| 297 |
except Exception:
|
| 298 |
-
return _completion(settings, request, 'unavailable', connected=False if
|
| 299 |
finally:
|
| 300 |
# Errors may leave Authlib's temporary state behind; bound its cookie
|
| 301 |
# size and prevent a replay without affecting an older OAuth login.
|
|
|
|
| 6 |
import hmac
|
| 7 |
import json
|
| 8 |
import os
|
| 9 |
+
import re
|
| 10 |
import secrets
|
| 11 |
import time
|
| 12 |
from urllib.parse import urlsplit
|
| 13 |
|
| 14 |
from fastapi import FastAPI, HTTPException, Request
|
| 15 |
+
from itsdangerous import BadSignature, TimestampSigner, URLSafeTimedSerializer
|
| 16 |
from starlette.middleware.sessions import SessionMiddleware
|
| 17 |
from starlette.requests import HTTPConnection
|
| 18 |
from starlette.responses import HTMLResponse, JSONResponse
|
|
|
|
| 26 |
FLOW_KEY = '_supra_history_oauth_flow'
|
| 27 |
ATTEMPT_KEY = '_supra_history_oauth_silent_at'
|
| 28 |
FLOW_LIFETIME = 600
|
| 29 |
+
CHECK_COOKIE = '__Host-supra-history-oauth-check'
|
| 30 |
|
| 31 |
|
| 32 |
@dataclass(frozen=True)
|
|
|
|
| 129 |
<style nonce="{nonce}">body{{background:#101010;color:#ddd;font:16px Arial,sans-serif;padding:32px}}a{{color:#f2cd70}}</style></head>
|
| 130 |
<body><p>{'Account connected. You can close this window and return to Supra-Tex.' if authenticated else 'You can close this window and return to Supra-Tex.'}</p>
|
| 131 |
<script nonce="{nonce}">(() => {{const message={payload};const origin={origin};if(window.parent!==window)window.parent.postMessage(message,origin);if(window.opener)window.opener.postMessage(message,origin);window.close();}})();</script></body></html>'''
|
| 132 |
+
response = HTMLResponse(html, headers={
|
| 133 |
'Cache-Control': 'private, no-store', 'Pragma': 'no-cache',
|
| 134 |
'Content-Security-Policy': f"default-src 'none'; script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'; base-uri 'none'; form-action 'none'; frame-ancestors 'self' https://huggingface.co",
|
| 135 |
'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff',
|
| 136 |
})
|
| 137 |
+
if request.scope.get('supra_history_clear_check_state'):
|
| 138 |
+
response.headers.append('set-cookie', f'{CHECK_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=None; Partitioned')
|
| 139 |
+
return response
|
| 140 |
|
| 141 |
|
| 142 |
def install_oauth_routes(app: FastAPI, *, settings: OAuthSettings | None = None, oauth_client=None,
|
|
|
|
| 158 |
install_session_write_guard(app)
|
| 159 |
bridges = bridges if bridges is not None else Bridges()
|
| 160 |
sessions = sessions if sessions is not None else AppSessions(settings.client_secret)
|
| 161 |
+
check_signer = URLSafeTimedSerializer(settings.client_secret, salt='supra-history-oauth-check-v1')
|
| 162 |
guards = BootstrapGuards(settings.client_secret)
|
| 163 |
app.add_middleware(AppSessionMiddleware, sessions=sessions)
|
| 164 |
app.state.supra_history_bridges = bridges
|
| 165 |
app.state.supra_history_app_sessions = sessions
|
| 166 |
|
| 167 |
+
async def authorize(request: Request, silent=False, bridge_id=None, refresh=False, check=None):
|
| 168 |
# Older Gradio sessions include a full ID token/profile. Once invalid,
|
| 169 |
# retaining them alongside PKCE state can overflow the browser's 4 KiB
|
| 170 |
# cookie limit and lose the new authorization state.
|
|
|
|
| 175 |
previous_attempt = float(request.session.get(ATTEMPT_KEY, 0))
|
| 176 |
except (ValueError, TypeError):
|
| 177 |
previous_attempt = now
|
| 178 |
+
if now - previous_attempt < (2 if refresh else 30):
|
| 179 |
return _completion(settings, request, 'unavailable')
|
| 180 |
request.session[ATTEMPT_KEY] = now
|
| 181 |
state = secrets.token_urlsafe(32)
|
|
|
|
| 184 |
if name.startswith('_state_supra_history_'):
|
| 185 |
request.session.pop(name, None)
|
| 186 |
request.session[FLOW_KEY] = {'state': state, 'created_at': now, 'silent': silent,
|
| 187 |
+
'refresh': refresh,
|
| 188 |
+
**({'check': check} if check else {}),
|
| 189 |
**({'bridge_id': bridge_id} if bridge_id else {})}
|
| 190 |
params = {'state': state, 'nonce': nonce}
|
| 191 |
if silent:
|
|
|
|
| 203 |
return _completion(settings, request, 'unavailable')
|
| 204 |
|
| 205 |
@app.get(AUTH_PATH + '/start')
|
| 206 |
+
async def start(request: Request, silent: bool = False, refresh: bool = False, check: str | None = None):
|
| 207 |
+
if not refresh and optional_identity(request) is not None:
|
| 208 |
return _completion(settings, request)
|
| 209 |
+
if refresh:
|
| 210 |
+
if not check or not re.fullmatch(r'[A-Za-z0-9_-]{32,128}', check):
|
| 211 |
+
raise HTTPException(400, 'Invalid account verification request.')
|
| 212 |
+
# HF and the embedded app have separate cookies. A still-valid app
|
| 213 |
+
# session is not proof of the account currently signed in on HF.
|
| 214 |
+
request.scope['supra_history_app_identity'] = None
|
| 215 |
+
request.session.pop('oauth_info', None)
|
| 216 |
+
response = await authorize(request, silent, refresh=refresh, check=check if refresh else None)
|
| 217 |
+
if refresh:
|
| 218 |
+
sessions.clear_cookie(response)
|
| 219 |
+
if response.status_code == 302:
|
| 220 |
+
# Retain Authlib's existing PKCE/nonce state in the same browser
|
| 221 |
+
# partition as the app. Silent checks must also work when an
|
| 222 |
+
# unpartitioned Gradio session cookie is blocked by the browser.
|
| 223 |
+
state = request.session[FLOW_KEY]['state']
|
| 224 |
+
saved = {key: value for key, value in request.session.items()
|
| 225 |
+
if key in (FLOW_KEY, ATTEMPT_KEY, '_state_supra_history_' + state)}
|
| 226 |
+
value = check_signer.dumps(saved)
|
| 227 |
+
if len(value) > 3500:
|
| 228 |
+
raise HTTPException(503, 'Account verification could not start. Please sign in again.')
|
| 229 |
+
response.headers.append('set-cookie', f'{CHECK_COOKIE}={value}; Path=/; Max-Age={FLOW_LIFETIME}; HttpOnly; Secure; SameSite=None; Partitioned')
|
| 230 |
+
return response
|
| 231 |
|
| 232 |
@app.get(AUTH_PATH + '/popup')
|
| 233 |
async def popup(request: Request):
|
|
|
|
| 278 |
async def callback(request: Request):
|
| 279 |
state = request.query_params.get('state', '')
|
| 280 |
flow = request.session.get(FLOW_KEY)
|
| 281 |
+
if not isinstance(flow, dict) or flow.get('state') != state:
|
| 282 |
+
try:
|
| 283 |
+
saved = check_signer.loads(request.cookies.get(CHECK_COOKIE, ''), max_age=FLOW_LIFETIME)
|
| 284 |
+
candidate = saved.get(FLOW_KEY) if isinstance(saved, dict) else None
|
| 285 |
+
if (isinstance(candidate, dict) and candidate.get('refresh') is True
|
| 286 |
+
and isinstance(candidate.get('state'), str) and len(state) < 256
|
| 287 |
+
and hmac.compare_digest(state, candidate['state'])):
|
| 288 |
+
request.session.update(saved)
|
| 289 |
+
flow = candidate
|
| 290 |
+
except (BadSignature, ValueError, TypeError):
|
| 291 |
+
pass
|
| 292 |
valid = isinstance(flow, dict) and isinstance(flow.get('state'), str)
|
| 293 |
if valid:
|
| 294 |
try:
|
|
|
|
| 300 |
# here first, including denied/failed callbacks, and preserve newer flows.
|
| 301 |
if not valid:
|
| 302 |
return _completion(settings, request, 'unavailable')
|
| 303 |
+
request.scope['supra_history_clear_check_state'] = bool(flow.get('refresh'))
|
| 304 |
bridge_id = flow.get('bridge_id')
|
| 305 |
+
fresh = bool(bridge_id or flow.get('refresh'))
|
| 306 |
try:
|
| 307 |
token = await oauth_client.authorize_access_token(request)
|
| 308 |
if not isinstance(token, dict) or not token.get('id_token') or not isinstance(token.get('userinfo'), dict):
|
| 309 |
+
return _completion(settings, request, 'unavailable', connected=False if fresh else None)
|
| 310 |
profile = token['userinfo']
|
| 311 |
verified = {key: profile[key] for key in ('sub', 'preferred_username', 'name', 'picture', 'profile') if key in profile}
|
| 312 |
candidate = {'access_token': token.get('access_token'), 'expires_at': token.get('expires_at'),
|
|
|
|
| 322 |
request.session.pop('oauth_info', None)
|
| 323 |
else:
|
| 324 |
request.session['oauth_info'] = old
|
| 325 |
+
return _completion(settings, request, 'unavailable', connected=False if fresh else None)
|
| 326 |
if bridge_id:
|
| 327 |
try:
|
| 328 |
expires = min(float(candidate['expires_at']), float(profile['exp']))
|
|
|
|
| 330 |
bridges.finish(bridge_id)
|
| 331 |
return _completion(settings, request, 'unavailable', connected=False)
|
| 332 |
claims = sessions.claims(identity, expires)
|
| 333 |
+
claims['check'] = bridge_id
|
| 334 |
if sessions.validate(claims) is None or not bridges.finish(bridge_id, claims):
|
| 335 |
return _completion(settings, request, 'unavailable', connected=False)
|
| 336 |
+
response = _completion(settings, request, connected=True)
|
| 337 |
+
if not bridge_id:
|
| 338 |
+
# Silent OAuth runs in the original page's embedding partition.
|
| 339 |
+
# Replace its application cookie as well as Gradio's session;
|
| 340 |
+
# otherwise the old app cookie would shadow the new HF account.
|
| 341 |
+
claims = sessions.claims(identity, candidate['expires_at'])
|
| 342 |
+
if flow.get('check'):
|
| 343 |
+
claims['check'] = flow['check']
|
| 344 |
+
sessions.set_cookie(response, claims)
|
| 345 |
+
return response
|
| 346 |
except OAuthError as exc:
|
| 347 |
return _completion(settings, request, getattr(exc, 'error', 'unavailable'),
|
| 348 |
+
connected=False if fresh else None)
|
| 349 |
except Exception:
|
| 350 |
+
return _completion(settings, request, 'unavailable', connected=False if fresh else None)
|
| 351 |
finally:
|
| 352 |
# Errors may leave Authlib's temporary state behind; bound its cookie
|
| 353 |
# size and prevent a replay without affecting an older OAuth login.
|
hf_space/history_routes.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import hashlib
|
|
|
|
| 5 |
import json
|
| 6 |
import logging
|
| 7 |
import mimetypes
|
|
@@ -16,7 +17,7 @@ from urllib.parse import unquote, urlsplit
|
|
| 16 |
import uuid
|
| 17 |
|
| 18 |
import anyio
|
| 19 |
-
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
| 20 |
from starlette.formparsers import MultiPartException
|
| 21 |
from starlette.responses import FileResponse, JSONResponse
|
| 22 |
|
|
@@ -235,7 +236,15 @@ def install_routes(
|
|
| 235 |
All providers and Store methods run in worker threads. The Store is the
|
| 236 |
authorization authority and must check owner before looking up any file.
|
| 237 |
"""
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
cache = private_file_cache if private_file_cache is not None else PrivateFileCache(private_cache_root)
|
| 240 |
# Cold artifact reads can block on the mounted Bucket. Keep their workers
|
| 241 |
# separate from metadata/status requests and bound the number of live reads.
|
|
@@ -480,6 +489,7 @@ def install_routes(
|
|
| 480 |
return {'authenticated': identity is not None,
|
| 481 |
'username': identity.username if identity else None,
|
| 482 |
'account_key': account_key(identity, csrf_secret) if identity else None,
|
|
|
|
| 483 |
'csrf_token': csrf_token(identity, csrf_secret) if identity else None,
|
| 484 |
'login_url': oauth_endpoints['login_url'] if oauth_endpoints else '/login/huggingface',
|
| 485 |
'silent_login_url': oauth_endpoints['silent_login_url'] if oauth_endpoints else None,
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import hashlib
|
| 5 |
+
import hmac
|
| 6 |
import json
|
| 7 |
import logging
|
| 8 |
import mimetypes
|
|
|
|
| 17 |
import uuid
|
| 18 |
|
| 19 |
import anyio
|
| 20 |
+
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
|
| 21 |
from starlette.formparsers import MultiPartException
|
| 22 |
from starlette.responses import FileResponse, JSONResponse
|
| 23 |
|
|
|
|
| 236 |
All providers and Store methods run in worker threads. The Store is the
|
| 237 |
authorization authority and must check owner before looking up any file.
|
| 238 |
"""
|
| 239 |
+
def require_account_binding(request: Request):
|
| 240 |
+
supplied = request.headers.get('x-supra-account')
|
| 241 |
+
if supplied is not None:
|
| 242 |
+
identity = require_identity(request)
|
| 243 |
+
if not re.fullmatch(r'[0-9a-f]{64}', supplied) or not hmac.compare_digest(supplied, account_key(identity, csrf_secret)):
|
| 244 |
+
raise HTTPException(409, 'Your account changed. Verify your current account to continue.',
|
| 245 |
+
headers={'X-Supra-Account-Changed': '1'})
|
| 246 |
+
|
| 247 |
+
router = APIRouter(prefix=BASE, dependencies=[Depends(require_account_binding)])
|
| 248 |
cache = private_file_cache if private_file_cache is not None else PrivateFileCache(private_cache_root)
|
| 249 |
# Cold artifact reads can block on the mounted Bucket. Keep their workers
|
| 250 |
# separate from metadata/status requests and bound the number of live reads.
|
|
|
|
| 489 |
return {'authenticated': identity is not None,
|
| 490 |
'username': identity.username if identity else None,
|
| 491 |
'account_key': account_key(identity, csrf_secret) if identity else None,
|
| 492 |
+
'verification_key': (request.scope.get('supra_history_app_claims') or {}).get('check') if identity else None,
|
| 493 |
'csrf_token': csrf_token(identity, csrf_secret) if identity else None,
|
| 494 |
'login_url': oauth_endpoints['login_url'] if oauth_endpoints else '/login/huggingface',
|
| 495 |
'silent_login_url': oauth_endpoints['silent_login_url'] if oauth_endpoints else None,
|
hf_space/history_ui.css
CHANGED
|
@@ -25,6 +25,7 @@ html:has(#st-history-shell),body:has(#st-history-shell){height:var(--st-availabl
|
|
| 25 |
#st-app.st-history-app{height:var(--st-workspace-height,calc(var(--st-available-height,100dvh) - 190px));width:100%;padding:0 clamp(12px,1.2vw,24px);margin:0;background:transparent;font-family:Arial,sans-serif}
|
| 26 |
#st-app.st-history-app [hidden]{display:none!important}
|
| 27 |
#st-app.st-history-app #st-workspace{position:relative;display:grid!important;grid-template-columns:minmax(260px,1fr) minmax(260px,1fr) clamp(112px,9vw,154px) clamp(112px,9vw,154px);gap:12px!important;height:100%;margin:0;min-height:0;align-items:stretch!important}
|
|
|
|
| 28 |
#st-app.st-history-app .st-native-panel{display:flex!important;flex-direction:column!important;gap:9px!important;border:1px solid #373735;border-radius:15px;background:#191919;padding:12px!important;height:100%;min-width:0;min-height:0;overflow:hidden;box-shadow:none}
|
| 29 |
#st-app.st-history-app #st-input-panel{grid-column:1;overflow:auto}
|
| 30 |
#st-app.st-history-app #st-input-panel[data-busy=true]{pointer-events:none;border-color:#494337}
|
|
|
|
| 25 |
#st-app.st-history-app{height:var(--st-workspace-height,calc(var(--st-available-height,100dvh) - 190px));width:100%;padding:0 clamp(12px,1.2vw,24px);margin:0;background:transparent;font-family:Arial,sans-serif}
|
| 26 |
#st-app.st-history-app [hidden]{display:none!important}
|
| 27 |
#st-app.st-history-app #st-workspace{position:relative;display:grid!important;grid-template-columns:minmax(260px,1fr) minmax(260px,1fr) clamp(112px,9vw,154px) clamp(112px,9vw,154px);gap:12px!important;height:100%;margin:0;min-height:0;align-items:stretch!important}
|
| 28 |
+
#st-app.st-history-app #st-workspace[data-account-checking=true]{visibility:hidden}
|
| 29 |
#st-app.st-history-app .st-native-panel{display:flex!important;flex-direction:column!important;gap:9px!important;border:1px solid #373735;border-radius:15px;background:#191919;padding:12px!important;height:100%;min-width:0;min-height:0;overflow:hidden;box-shadow:none}
|
| 30 |
#st-app.st-history-app #st-input-panel{grid-column:1;overflow:auto}
|
| 31 |
#st-app.st-history-app #st-input-panel[data-busy=true]{pointer-events:none;border-color:#494337}
|
hf_space/history_ui.js
CHANGED
|
@@ -24,6 +24,7 @@
|
|
| 24 |
localURL: null, silentAttempted: false, silentFrame: null, silentTimer: null,
|
| 25 |
authPopup: null, authTimer: null, authBootstrapTimer: null, authFlow: null, authVersion: 0, authBusy: false,
|
| 26 |
authFallback: false, identityPromise: null, authChecking: true, autoAuthAttempted: false, authMessage: ''};
|
|
|
|
| 27 |
const gpuDispatcher = window.SupraZeroGPU.createDispatcher({
|
| 28 |
identity: () => S.identity, version: () => S.identityVersion, stopped: () => S.stopped,
|
| 29 |
ready: record => operationReady(S.deployment, record),
|
|
@@ -288,7 +289,8 @@
|
|
| 288 |
S.silentFrame?.remove(); S.silentFrame = null;
|
| 289 |
}
|
| 290 |
function syncAccount() {
|
| 291 |
-
const signedIn = !!S.identity?.authenticated;
|
|
|
|
| 292 |
show('st-history-account', S.expanded && signedIn);
|
| 293 |
text('st-history-username', signedIn ? S.identity.username : '');
|
| 294 |
$('st-workspace').inert = !signedIn;
|
|
@@ -310,29 +312,48 @@
|
|
| 310 |
S.autoAuthAttempted = true; connectAccount();
|
| 311 |
}
|
| 312 |
}
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
endSilent();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
try {
|
| 316 |
await Promise.resolve(S.identityPromise).catch(() => {});
|
| 317 |
if (S.stopped) return;
|
| 318 |
-
const identity = await refreshIdentity();
|
| 319 |
if (identity.authenticated) return refreshJobs();
|
| 320 |
-
|
| 321 |
} catch {
|
| 322 |
-
|
| 323 |
-
requireEntrySignIn();
|
| 324 |
}
|
| 325 |
}
|
| 326 |
function startSilentAuth() {
|
| 327 |
-
if (S.silentAttempted || S.identity?.authenticated || S.stopped) return;
|
| 328 |
S.silentAttempted = true;
|
| 329 |
let signedOut = false;
|
| 330 |
try { signedOut = sessionStorage.getItem('st-history-signed-out') === '1'; } catch {}
|
| 331 |
const url = authURL(S.identity?.silent_login_url);
|
| 332 |
-
if (!url || signedOut) { S.authFallback = true;
|
| 333 |
S.authChecking = true; S.authMessage = '';
|
| 334 |
const frame = S.silentFrame = document.createElement('iframe');
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
| 336 |
document.body.append(frame);
|
| 337 |
syncAccount();
|
| 338 |
S.silentTimer = setTimeout(() => void finishSilentAuth(), 12000);
|
|
@@ -376,7 +397,7 @@
|
|
| 376 |
// Neither opener access nor the popup's cookies are needed here.
|
| 377 |
await Promise.resolve(S.identityPromise).catch(() => {});
|
| 378 |
if (version !== S.authVersion || S.stopped) return;
|
| 379 |
-
const identity = await refreshIdentity();
|
| 380 |
if (!identity.authenticated) return accountConnectionFailed('Your browser could not save the account connection. Please try again.');
|
| 381 |
notice('Signed in with Huggingface. You can now use Supra-Tex.');
|
| 382 |
await refreshJobs(); return;
|
|
@@ -456,8 +477,10 @@
|
|
| 456 |
}
|
| 457 |
async function api(path, options = {}) {
|
| 458 |
const privateCall = !/^\/(?:me|status|examples)(?:\?|$)/.test(path);
|
|
|
|
| 459 |
const identityVersion = S.identityVersion;
|
| 460 |
const headers = {...options.headers};
|
|
|
|
| 461 |
if (options.json !== undefined) headers['Content-Type'] = 'application/json';
|
| 462 |
if ((options.method || 'GET') !== 'GET' && S.identity?.csrf_token) headers['X-CSRF-Token'] = S.identity.csrf_token;
|
| 463 |
const method = options.method || 'GET';
|
|
@@ -480,13 +503,17 @@
|
|
| 480 |
if (method !== 'GET' || attempt) throw new Error('The connection timed out. Please try again.');
|
| 481 |
}
|
| 482 |
}
|
| 483 |
-
if (privateCall && identityVersion !== S.identityVersion) throw new DOMException('Account changed', 'AbortError');
|
| 484 |
if (response.status === 401) {
|
| 485 |
S.identity = {authenticated: false}; S.authChecking = false;
|
| 486 |
S.authMessage = 'Your session has expired. Please sign in again to continue.';
|
| 487 |
endAccountConnection(); clearPrivate(); updateGate(); syncAccount();
|
| 488 |
throw new Error('Please authorize your Huggingface account to access your private history.');
|
| 489 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
if (!response.ok) {
|
| 491 |
const detail = typeof data.detail === 'string' ? data.detail : data.error;
|
| 492 |
const error = new Error(englishMessage(detail, 'Could not complete this request. Please try again.'));
|
|
@@ -501,21 +528,31 @@
|
|
| 501 |
gpuDispatcher.observe(data, {requested: method === 'POST' && path !== '/gpu/prepare'});
|
| 502 |
return data;
|
| 503 |
}
|
| 504 |
-
async function refreshIdentity() {
|
| 505 |
if (S.identityPromise) return S.identityPromise;
|
| 506 |
-
S.identityPromise = refreshIdentityInner().finally(() => { S.identityPromise = null; });
|
| 507 |
return S.identityPromise;
|
| 508 |
}
|
| 509 |
-
async function refreshIdentityInner() {
|
| 510 |
const identityVersion = S.identityVersion;
|
| 511 |
const identity = await api('/me');
|
| 512 |
if (identityVersion !== S.identityVersion) return S.identity || {authenticated: false};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
const previous = S.identity;
|
| 514 |
const preserve = previous && !previous.authenticated && identity.authenticated &&
|
| 515 |
(S.source?.local_file || S.source?.example) ? {source: S.source, seed: $('st-seed').value} : null;
|
| 516 |
if (previous && (previous.account_key !== identity.account_key ||
|
| 517 |
previous.username !== identity.username || previous.authenticated !== identity.authenticated)) clearPrivate();
|
| 518 |
S.identity = identity; S.lastIdentityCheck = Date.now();
|
|
|
|
| 519 |
window.SupraPrivateAssets?.setAccount(identity.authenticated ? identity.account_key || identity.username : '');
|
| 520 |
if (preserve?.source.local_file) stageLocalFile(preserve.source.local_file, preserve.seed);
|
| 521 |
else if (preserve?.source.example) {
|
|
@@ -543,7 +580,7 @@
|
|
| 543 |
updateGate();
|
| 544 |
}
|
| 545 |
function updateGate() {
|
| 546 |
-
const signedIn = !!S.identity?.authenticated, loading = !ready(S.deployment);
|
| 547 |
const inputBusy = leftInputBusy(), geometry = selectedGeometry();
|
| 548 |
$('st-input-panel').inert = inputBusy;
|
| 549 |
$('st-input-panel').dataset.busy = String(inputBusy);
|
|
@@ -732,12 +769,12 @@
|
|
| 732 |
}
|
| 733 |
function scheduleJobs() {
|
| 734 |
clearTimeout(S.jobsTimer); S.jobsTimer = null;
|
| 735 |
-
if (!S.stopped && S.identity?.authenticated && (!S.historyLoaded || S.historyFailures || [...S.jobs.values()].some(pending)))
|
| 736 |
S.jobsTimer = setTimeout(() => refreshJobs().catch(() => {}),
|
| 737 |
document.hidden ? 20000 : S.historyLoaded ? 4000 : 5000);
|
| 738 |
}
|
| 739 |
async function refreshJobs(more = false) {
|
| 740 |
-
if (!S.identity?.authenticated || S.stopped) return;
|
| 741 |
if (S.refreshPromise) return S.refreshPromise;
|
| 742 |
const identityVersion = S.identityVersion;
|
| 743 |
S.pageLoading = true; renderHistory();
|
|
@@ -1052,7 +1089,7 @@
|
|
| 1052 |
return S.examplesPromise;
|
| 1053 |
}
|
| 1054 |
async function activateWorkspace() {
|
| 1055 |
-
if (!S.identity?.authenticated || !ready(S.deployment) || S.stopped) return;
|
| 1056 |
await Promise.allSettled([loadExamples(), refreshJobs()]);
|
| 1057 |
if (S.source?.local_file && !S.source.upload_id && S.source.foreground_required &&
|
| 1058 |
(!S.uploadState || S.uploadState.phase === 'waiting_service'))
|
|
@@ -1448,7 +1485,7 @@
|
|
| 1448 |
// Interactive sign-in uses the one-time server handoff. HF's login page
|
| 1449 |
// can sever window.opener; its completion message is only a hint, not proof.
|
| 1450 |
if (!silent) return;
|
| 1451 |
-
void finishSilentAuth();
|
| 1452 |
});
|
| 1453 |
window.addEventListener('resize', () => { positionHistory(); updateGate(); });
|
| 1454 |
window.addEventListener('scroll', positionHistory, {passive: true});
|
|
@@ -1460,20 +1497,33 @@
|
|
| 1460 |
// Keep polling the handoff rather than treating that as a cancelled login.
|
| 1461 |
if (!S.authFlow && S.authPopup?.closed) { S.authPopup = null; S.authBusy = false; updateGate(); }
|
| 1462 |
if (S.authFlow) { void refreshStatus(); return; }
|
| 1463 |
-
if (document.hidden || Date.now() - S.lastIdentityCheck < 2000) return;
|
| 1464 |
try {
|
|
|
|
|
|
|
|
|
|
| 1465 |
const identity = await refreshIdentity();
|
| 1466 |
if (identity.authenticated) await refreshJobs();
|
| 1467 |
-
else if (!S.silentFrame) requireEntrySignIn();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1468 |
}
|
| 1469 |
-
catch (error) { if (!quietError(error)) notice('Could not reconnect. Please refresh to retry.'); }
|
| 1470 |
void refreshStatus();
|
| 1471 |
}
|
| 1472 |
document.addEventListener('visibilitychange', () => {
|
| 1473 |
clearTimeout(S.jobsTimer); clearTimeout(S.startupTimer);
|
| 1474 |
-
if (document.hidden) { scheduleJobs(); if (pollStartup()) S.startupTimer = setTimeout(refreshStatus, 20000); }
|
| 1475 |
else void resume();
|
| 1476 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1477 |
window.addEventListener('focus', () => void resume());
|
| 1478 |
window.addEventListener('pagehide', () => {
|
| 1479 |
S.stopped = true; clearTimeout(S.jobsTimer); clearTimeout(S.startupTimer); clearTimeout(S.noticeTimer);
|
|
@@ -1482,15 +1532,13 @@
|
|
| 1482 |
});
|
| 1483 |
window.addEventListener('pageshow', event => { if (event.persisted) { S.stopped = false; S.lastIdentityCheck = 0; void resume(); } });
|
| 1484 |
async function boot() {
|
|
|
|
| 1485 |
syncAccount(); updateGate(); void refreshStatus(); void loadExamples();
|
| 1486 |
try {
|
| 1487 |
-
|
| 1488 |
-
if (identity.authenticated) await refreshJobs();
|
| 1489 |
-
else { startSilentAuth(); renderHistory(); }
|
| 1490 |
}
|
| 1491 |
catch {
|
| 1492 |
-
|
| 1493 |
-
requireEntrySignIn(false);
|
| 1494 |
}
|
| 1495 |
}
|
| 1496 |
void boot();
|
|
|
|
| 24 |
localURL: null, silentAttempted: false, silentFrame: null, silentTimer: null,
|
| 25 |
authPopup: null, authTimer: null, authBootstrapTimer: null, authFlow: null, authVersion: 0, authBusy: false,
|
| 26 |
authFallback: false, identityPromise: null, authChecking: true, autoAuthAttempted: false, authMessage: ''};
|
| 27 |
+
S.verifyRequired = false; S.accountNeedsCheck = true; S.accountCheck = null;
|
| 28 |
const gpuDispatcher = window.SupraZeroGPU.createDispatcher({
|
| 29 |
identity: () => S.identity, version: () => S.identityVersion, stopped: () => S.stopped,
|
| 30 |
ready: record => operationReady(S.deployment, record),
|
|
|
|
| 289 |
S.silentFrame?.remove(); S.silentFrame = null;
|
| 290 |
}
|
| 291 |
function syncAccount() {
|
| 292 |
+
const signedIn = !!S.identity?.authenticated && !S.verifyRequired;
|
| 293 |
+
$('st-workspace').dataset.accountChecking = String(S.verifyRequired);
|
| 294 |
show('st-history-account', S.expanded && signedIn);
|
| 295 |
text('st-history-username', signedIn ? S.identity.username : '');
|
| 296 |
$('st-workspace').inert = !signedIn;
|
|
|
|
| 312 |
S.autoAuthAttempted = true; connectAccount();
|
| 313 |
}
|
| 314 |
}
|
| 315 |
+
function beginAccountVerification() {
|
| 316 |
+
S.verifyRequired = true; S.accountNeedsCheck = false;
|
| 317 |
+
S.accountCheck = randomAuthSecret(); S.silentAttempted = false;
|
| 318 |
+
S.authChecking = true; S.authMessage = '';
|
| 319 |
+
clearTimeout(S.jobsTimer); S.jobsTimer = null;
|
| 320 |
+
$('st-delete-dialog').close();
|
| 321 |
+
syncAccount(); updateGate();
|
| 322 |
+
}
|
| 323 |
+
function accountVerificationFailed(message = '') {
|
| 324 |
endSilent();
|
| 325 |
+
S.identity = {...S.identity, authenticated: false};
|
| 326 |
+
S.verifyRequired = false; S.accountCheck = null; S.authChecking = false;
|
| 327 |
+
S.authMessage = message;
|
| 328 |
+
clearPrivate(); updateGate(); syncAccount(); requireEntrySignIn(false);
|
| 329 |
+
}
|
| 330 |
+
async function finishSilentAuth(authenticated = false) {
|
| 331 |
+
const check = S.accountCheck;
|
| 332 |
+
endSilent();
|
| 333 |
+
if (!authenticated) return accountVerificationFailed();
|
| 334 |
try {
|
| 335 |
await Promise.resolve(S.identityPromise).catch(() => {});
|
| 336 |
if (S.stopped) return;
|
| 337 |
+
const identity = await refreshIdentity({verificationKey: check});
|
| 338 |
if (identity.authenticated) return refreshJobs();
|
| 339 |
+
accountVerificationFailed();
|
| 340 |
} catch {
|
| 341 |
+
accountVerificationFailed('Could not verify your current account. Please sign in to continue.');
|
|
|
|
| 342 |
}
|
| 343 |
}
|
| 344 |
function startSilentAuth() {
|
| 345 |
+
if (S.silentAttempted || (S.identity?.authenticated && !S.verifyRequired) || S.stopped) return;
|
| 346 |
S.silentAttempted = true;
|
| 347 |
let signedOut = false;
|
| 348 |
try { signedOut = sessionStorage.getItem('st-history-signed-out') === '1'; } catch {}
|
| 349 |
const url = authURL(S.identity?.silent_login_url);
|
| 350 |
+
if (!url || signedOut) { S.authFallback = true; accountVerificationFailed(); return; }
|
| 351 |
S.authChecking = true; S.authMessage = '';
|
| 352 |
const frame = S.silentFrame = document.createElement('iframe');
|
| 353 |
+
const verificationURL = new URL(url);
|
| 354 |
+
verificationURL.searchParams.set('refresh', '1');
|
| 355 |
+
verificationURL.searchParams.set('check', S.accountCheck);
|
| 356 |
+
frame.hidden = true; frame.title = 'Verify Huggingface account'; frame.src = verificationURL.href;
|
| 357 |
document.body.append(frame);
|
| 358 |
syncAccount();
|
| 359 |
S.silentTimer = setTimeout(() => void finishSilentAuth(), 12000);
|
|
|
|
| 397 |
// Neither opener access nor the popup's cookies are needed here.
|
| 398 |
await Promise.resolve(S.identityPromise).catch(() => {});
|
| 399 |
if (version !== S.authVersion || S.stopped) return;
|
| 400 |
+
const identity = await refreshIdentity({verificationKey: flow.bridge_id});
|
| 401 |
if (!identity.authenticated) return accountConnectionFailed('Your browser could not save the account connection. Please try again.');
|
| 402 |
notice('Signed in with Huggingface. You can now use Supra-Tex.');
|
| 403 |
await refreshJobs(); return;
|
|
|
|
| 477 |
}
|
| 478 |
async function api(path, options = {}) {
|
| 479 |
const privateCall = !/^\/(?:me|status|examples)(?:\?|$)/.test(path);
|
| 480 |
+
if (privateCall && S.verifyRequired) throw new DOMException('Account verification pending', 'AbortError');
|
| 481 |
const identityVersion = S.identityVersion;
|
| 482 |
const headers = {...options.headers};
|
| 483 |
+
if (privateCall && S.identity?.account_key) headers['X-Supra-Account'] = S.identity.account_key;
|
| 484 |
if (options.json !== undefined) headers['Content-Type'] = 'application/json';
|
| 485 |
if ((options.method || 'GET') !== 'GET' && S.identity?.csrf_token) headers['X-CSRF-Token'] = S.identity.csrf_token;
|
| 486 |
const method = options.method || 'GET';
|
|
|
|
| 503 |
if (method !== 'GET' || attempt) throw new Error('The connection timed out. Please try again.');
|
| 504 |
}
|
| 505 |
}
|
| 506 |
+
if (privateCall && (identityVersion !== S.identityVersion || S.verifyRequired)) throw new DOMException('Account changed', 'AbortError');
|
| 507 |
if (response.status === 401) {
|
| 508 |
S.identity = {authenticated: false}; S.authChecking = false;
|
| 509 |
S.authMessage = 'Your session has expired. Please sign in again to continue.';
|
| 510 |
endAccountConnection(); clearPrivate(); updateGate(); syncAccount();
|
| 511 |
throw new Error('Please authorize your Huggingface account to access your private history.');
|
| 512 |
}
|
| 513 |
+
if (response.status === 409 && (data.code === 'account_changed' || response.headers.get('X-Supra-Account-Changed') === '1')) {
|
| 514 |
+
clearPrivate(); beginAccountVerification(); startSilentAuth();
|
| 515 |
+
throw new DOMException('Account changed', 'AbortError');
|
| 516 |
+
}
|
| 517 |
if (!response.ok) {
|
| 518 |
const detail = typeof data.detail === 'string' ? data.detail : data.error;
|
| 519 |
const error = new Error(englishMessage(detail, 'Could not complete this request. Please try again.'));
|
|
|
|
| 528 |
gpuDispatcher.observe(data, {requested: method === 'POST' && path !== '/gpu/prepare'});
|
| 529 |
return data;
|
| 530 |
}
|
| 531 |
+
async function refreshIdentity(options = {}) {
|
| 532 |
if (S.identityPromise) return S.identityPromise;
|
| 533 |
+
S.identityPromise = refreshIdentityInner(options).finally(() => { S.identityPromise = null; });
|
| 534 |
return S.identityPromise;
|
| 535 |
}
|
| 536 |
+
async function refreshIdentityInner({verificationKey} = {}) {
|
| 537 |
const identityVersion = S.identityVersion;
|
| 538 |
const identity = await api('/me');
|
| 539 |
if (identityVersion !== S.identityVersion) return S.identity || {authenticated: false};
|
| 540 |
+
if (verificationKey && (!identity.authenticated || identity.verification_key !== verificationKey))
|
| 541 |
+
throw new Error('The current account could not be verified.');
|
| 542 |
+
if (S.verifyRequired && !verificationKey) {
|
| 543 |
+
// A valid app cookie can still belong to the previous HF account. Do not
|
| 544 |
+
// publish it or request any private history before fresh OAuth completes.
|
| 545 |
+
if (!S.identity) S.identity = {...identity, authenticated: false};
|
| 546 |
+
else for (const key of ['login_url', 'silent_login_url', 'bridge_url', 'popup_url']) S.identity[key] = identity[key];
|
| 547 |
+
return {authenticated: false};
|
| 548 |
+
}
|
| 549 |
const previous = S.identity;
|
| 550 |
const preserve = previous && !previous.authenticated && identity.authenticated &&
|
| 551 |
(S.source?.local_file || S.source?.example) ? {source: S.source, seed: $('st-seed').value} : null;
|
| 552 |
if (previous && (previous.account_key !== identity.account_key ||
|
| 553 |
previous.username !== identity.username || previous.authenticated !== identity.authenticated)) clearPrivate();
|
| 554 |
S.identity = identity; S.lastIdentityCheck = Date.now();
|
| 555 |
+
if (verificationKey) { S.verifyRequired = false; S.accountCheck = null; }
|
| 556 |
window.SupraPrivateAssets?.setAccount(identity.authenticated ? identity.account_key || identity.username : '');
|
| 557 |
if (preserve?.source.local_file) stageLocalFile(preserve.source.local_file, preserve.seed);
|
| 558 |
else if (preserve?.source.example) {
|
|
|
|
| 580 |
updateGate();
|
| 581 |
}
|
| 582 |
function updateGate() {
|
| 583 |
+
const signedIn = !!S.identity?.authenticated && !S.verifyRequired, loading = !ready(S.deployment);
|
| 584 |
const inputBusy = leftInputBusy(), geometry = selectedGeometry();
|
| 585 |
$('st-input-panel').inert = inputBusy;
|
| 586 |
$('st-input-panel').dataset.busy = String(inputBusy);
|
|
|
|
| 769 |
}
|
| 770 |
function scheduleJobs() {
|
| 771 |
clearTimeout(S.jobsTimer); S.jobsTimer = null;
|
| 772 |
+
if (!S.stopped && !S.verifyRequired && S.identity?.authenticated && (!S.historyLoaded || S.historyFailures || [...S.jobs.values()].some(pending)))
|
| 773 |
S.jobsTimer = setTimeout(() => refreshJobs().catch(() => {}),
|
| 774 |
document.hidden ? 20000 : S.historyLoaded ? 4000 : 5000);
|
| 775 |
}
|
| 776 |
async function refreshJobs(more = false) {
|
| 777 |
+
if (!S.identity?.authenticated || S.verifyRequired || S.stopped) return;
|
| 778 |
if (S.refreshPromise) return S.refreshPromise;
|
| 779 |
const identityVersion = S.identityVersion;
|
| 780 |
S.pageLoading = true; renderHistory();
|
|
|
|
| 1089 |
return S.examplesPromise;
|
| 1090 |
}
|
| 1091 |
async function activateWorkspace() {
|
| 1092 |
+
if (!S.identity?.authenticated || S.verifyRequired || !ready(S.deployment) || S.stopped) return;
|
| 1093 |
await Promise.allSettled([loadExamples(), refreshJobs()]);
|
| 1094 |
if (S.source?.local_file && !S.source.upload_id && S.source.foreground_required &&
|
| 1095 |
(!S.uploadState || S.uploadState.phase === 'waiting_service'))
|
|
|
|
| 1485 |
// Interactive sign-in uses the one-time server handoff. HF's login page
|
| 1486 |
// can sever window.opener; its completion message is only a hint, not proof.
|
| 1487 |
if (!silent) return;
|
| 1488 |
+
void finishSilentAuth(event.data.authenticated === true);
|
| 1489 |
});
|
| 1490 |
window.addEventListener('resize', () => { positionHistory(); updateGate(); });
|
| 1491 |
window.addEventListener('scroll', positionHistory, {passive: true});
|
|
|
|
| 1497 |
// Keep polling the handoff rather than treating that as a cancelled login.
|
| 1498 |
if (!S.authFlow && S.authPopup?.closed) { S.authPopup = null; S.authBusy = false; updateGate(); }
|
| 1499 |
if (S.authFlow) { void refreshStatus(); return; }
|
| 1500 |
+
if (document.hidden || (!S.accountNeedsCheck && Date.now() - S.lastIdentityCheck < 2000)) return;
|
| 1501 |
try {
|
| 1502 |
+
if (S.accountNeedsCheck) {
|
| 1503 |
+
beginAccountVerification(); await refreshIdentity(); startSilentAuth(); return;
|
| 1504 |
+
}
|
| 1505 |
const identity = await refreshIdentity();
|
| 1506 |
if (identity.authenticated) await refreshJobs();
|
| 1507 |
+
else if (!S.silentFrame) requireEntrySignIn(false);
|
| 1508 |
+
}
|
| 1509 |
+
catch (error) {
|
| 1510 |
+
if (S.verifyRequired) accountVerificationFailed('Could not verify your current account. Please sign in to continue.');
|
| 1511 |
+
else if (!quietError(error)) notice('Could not reconnect. Please refresh to retry.');
|
| 1512 |
}
|
|
|
|
| 1513 |
void refreshStatus();
|
| 1514 |
}
|
| 1515 |
document.addEventListener('visibilitychange', () => {
|
| 1516 |
clearTimeout(S.jobsTimer); clearTimeout(S.startupTimer);
|
| 1517 |
+
if (document.hidden) { shieldPrivateWorkspace(); scheduleJobs(); if (pollStartup()) S.startupTimer = setTimeout(refreshStatus, 20000); }
|
| 1518 |
else void resume();
|
| 1519 |
});
|
| 1520 |
+
function shieldPrivateWorkspace() {
|
| 1521 |
+
if (S.authFlow || S.authChecking || S.stopped) return;
|
| 1522 |
+
S.accountNeedsCheck = true;
|
| 1523 |
+
$('st-workspace').dataset.accountChecking = 'true';
|
| 1524 |
+
$('st-workspace').inert = true;
|
| 1525 |
+
}
|
| 1526 |
+
window.addEventListener('blur', shieldPrivateWorkspace);
|
| 1527 |
window.addEventListener('focus', () => void resume());
|
| 1528 |
window.addEventListener('pagehide', () => {
|
| 1529 |
S.stopped = true; clearTimeout(S.jobsTimer); clearTimeout(S.startupTimer); clearTimeout(S.noticeTimer);
|
|
|
|
| 1532 |
});
|
| 1533 |
window.addEventListener('pageshow', event => { if (event.persisted) { S.stopped = false; S.lastIdentityCheck = 0; void resume(); } });
|
| 1534 |
async function boot() {
|
| 1535 |
+
beginAccountVerification();
|
| 1536 |
syncAccount(); updateGate(); void refreshStatus(); void loadExamples();
|
| 1537 |
try {
|
| 1538 |
+
await refreshIdentity(); startSilentAuth(); renderHistory();
|
|
|
|
|
|
|
| 1539 |
}
|
| 1540 |
catch {
|
| 1541 |
+
accountVerificationFailed('Could not check your account. Please sign in to continue.');
|
|
|
|
| 1542 |
}
|
| 1543 |
}
|
| 1544 |
void boot();
|
tests/test_history_account_switch.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A fresh HF identity replaces both cookie formats before private history loads."""
|
| 2 |
+
from io import BytesIO
|
| 3 |
+
import time
|
| 4 |
+
from types import SimpleNamespace
|
| 5 |
+
|
| 6 |
+
from authlib.integrations.base_client import OAuthError
|
| 7 |
+
from fastapi import FastAPI, Request
|
| 8 |
+
from PIL import Image
|
| 9 |
+
import pytest
|
| 10 |
+
from starlette.middleware.sessions import SessionMiddleware
|
| 11 |
+
from starlette.responses import RedirectResponse
|
| 12 |
+
from starlette.testclient import TestClient
|
| 13 |
+
|
| 14 |
+
from hf_space.history_auth import Identity
|
| 15 |
+
from hf_space.history_bridge import AppSessions, COOKIE_NAME
|
| 16 |
+
from hf_space.history_oauth import install_oauth_routes, OAuthSettings
|
| 17 |
+
from hf_space.history_routes import install_routes
|
| 18 |
+
from hf_space.history_store import Store
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Provider:
|
| 22 |
+
def __init__(self):
|
| 23 |
+
self.account = 'bob'
|
| 24 |
+
self.error = None
|
| 25 |
+
self.params = None
|
| 26 |
+
self.exchanges = 0
|
| 27 |
+
self.framework = SimpleNamespace(clear_state_data=self.clear_state)
|
| 28 |
+
|
| 29 |
+
async def clear_state(self, session, state):
|
| 30 |
+
pass
|
| 31 |
+
|
| 32 |
+
async def authorize_redirect(self, request, redirect_uri, **params):
|
| 33 |
+
self.params = params
|
| 34 |
+
request.session['_state_supra_history_' + params['state']] = {
|
| 35 |
+
'data': {'nonce': params['nonce'], 'code_verifier': 'test-pkce-verifier'}}
|
| 36 |
+
return RedirectResponse('https://huggingface.co/oauth/authorize?state=' + params['state'], status_code=302)
|
| 37 |
+
|
| 38 |
+
async def authorize_access_token(self, request):
|
| 39 |
+
self.exchanges += 1
|
| 40 |
+
data = request.session['_state_supra_history_' + self.params['state']]['data']
|
| 41 |
+
assert data['nonce'] == self.params['nonce']
|
| 42 |
+
assert data['code_verifier'] == 'test-pkce-verifier'
|
| 43 |
+
if self.error:
|
| 44 |
+
raise OAuthError(error=self.error)
|
| 45 |
+
return {'id_token': 'verified-by-mock-oidc-provider', 'access_token': 'mock-access-token',
|
| 46 |
+
'expires_at': time.time() + 3600,
|
| 47 |
+
'userinfo': {'sub': self.account, 'preferred_username': self.account,
|
| 48 |
+
'exp': time.time() + 3600}}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@pytest.fixture
|
| 52 |
+
def env(tmp_path, monkeypatch):
|
| 53 |
+
monkeypatch.setenv('SPACES_ZERO_GPU', 'true')
|
| 54 |
+
for name in ('SPACE_ID', 'SPACE_HOST', 'OAUTH_CLIENT_ID', 'OAUTH_CLIENT_SECRET'):
|
| 55 |
+
monkeypatch.delenv(name, raising=False)
|
| 56 |
+
store = Store(tmp_path/'history', asset_root=tmp_path/'assets').initialize()
|
| 57 |
+
stream = BytesIO()
|
| 58 |
+
Image.new('RGB', (12, 12), 'red').save(stream, 'PNG')
|
| 59 |
+
jobs = {}
|
| 60 |
+
for owner in ('alice', 'bob'):
|
| 61 |
+
upload = store.save_upload(owner, stream.getvalue())
|
| 62 |
+
jobs[owner] = store.submit(owner, upload['id'], 42, owner + '-request')
|
| 63 |
+
app = FastAPI()
|
| 64 |
+
app.add_middleware(SessionMiddleware, secret_key='test-gradio-session', https_only=True, same_site='none')
|
| 65 |
+
provider = Provider()
|
| 66 |
+
sessions = AppSessions('test-partitioned-session')
|
| 67 |
+
settings = OAuthSettings('test-client', 'test-partitioned-session', 'https://app.example')
|
| 68 |
+
install_oauth_routes(app, settings=settings, oauth_client=provider, sessions=sessions)
|
| 69 |
+
install_routes(app, store, deployment_status=lambda: {'gpu_mode': 'zerogpu', 'workspace': {'stage': 'ready'}},
|
| 70 |
+
private_cache_root=tmp_path/'private', trusted_origins=[settings.origin])
|
| 71 |
+
|
| 72 |
+
@app.get('/seed-legacy')
|
| 73 |
+
async def seed_legacy(request: Request):
|
| 74 |
+
request.session['oauth_info'] = {'access_token': 'old-token', 'expires_at': time.time()+3600,
|
| 75 |
+
'userinfo': {'sub': 'alice', 'preferred_username': 'alice'}}
|
| 76 |
+
return {}
|
| 77 |
+
|
| 78 |
+
client = TestClient(app, base_url=settings.origin)
|
| 79 |
+
client.get('/seed-legacy')
|
| 80 |
+
claims = sessions.claims(Identity('alice', 'alice', 'old-session'), time.time()+3600)
|
| 81 |
+
client.cookies.set(COOKIE_NAME, sessions.serializer.dumps(claims), domain='app.example', path='/')
|
| 82 |
+
return SimpleNamespace(client=client, provider=provider, sessions=sessions, jobs=jobs)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def begin(env, check='c' * 43):
|
| 86 |
+
response = env.client.get('/api/history/auth/start', params={'silent': '1', 'refresh': '1', 'check': check},
|
| 87 |
+
follow_redirects=False)
|
| 88 |
+
assert response.status_code == 302
|
| 89 |
+
assert env.provider.params['prompt'] == 'none'
|
| 90 |
+
return response
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def finish(env):
|
| 94 |
+
return env.client.get('/api/history/auth/callback', params={'state': env.provider.params['state'], 'code': 'mock-code'})
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_refresh_never_shortcuts_old_app_login_and_clears_both_cookie_formats(env):
|
| 98 |
+
assert env.client.get('/api/history/me').json()['username'] == 'alice'
|
| 99 |
+
response = begin(env)
|
| 100 |
+
cleared = [v for v in response.headers.get_list('set-cookie') if v.startswith(COOKIE_NAME+'=')]
|
| 101 |
+
assert len(cleared) == 2 and all('Max-Age=0' in v for v in cleared)
|
| 102 |
+
assert any('Partitioned' in v for v in cleared)
|
| 103 |
+
assert env.client.get('/api/history/me').json()['authenticated'] is False
|
| 104 |
+
assert env.client.get('/api/history/jobs').status_code == 401
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_silent_recheck_replaces_old_account_and_only_returns_new_owner_history(env):
|
| 108 |
+
begin(env)
|
| 109 |
+
response = finish(env)
|
| 110 |
+
assert response.status_code == 200
|
| 111 |
+
identity = env.client.get('/api/history/me').json()
|
| 112 |
+
assert identity['username'] == 'bob'
|
| 113 |
+
assert identity['verification_key'] == 'c'*43
|
| 114 |
+
result = env.client.get('/api/history/jobs', headers={'X-Supra-Account': identity['account_key']})
|
| 115 |
+
assert result.status_code == 200
|
| 116 |
+
assert [j['id'] for j in result.json()['items']] == [env.jobs['bob']['id']]
|
| 117 |
+
assert env.client.get('/api/history/jobs/'+env.jobs['alice']['id']).status_code == 404
|
| 118 |
+
assert env.client.get('/api/history/jobs/'+env.jobs['alice']['id']+'/files/input.png').status_code == 404
|
| 119 |
+
assert 'no-store' in result.headers['cache-control']
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_logged_out_hf_or_failed_recheck_never_reuses_account_a(env):
|
| 123 |
+
begin(env)
|
| 124 |
+
env.provider.error = 'login_required'
|
| 125 |
+
response = finish(env)
|
| 126 |
+
assert '"authenticated":false' in response.text
|
| 127 |
+
assert env.client.get('/api/history/me').json()['authenticated'] is False
|
| 128 |
+
assert env.client.get('/api/history/jobs').status_code == 401
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_stale_tab_account_header_rejects_cookie_changed_by_another_tab(env):
|
| 132 |
+
old_key = env.client.get('/api/history/me').json()['account_key']
|
| 133 |
+
begin(env)
|
| 134 |
+
finish(env)
|
| 135 |
+
response = env.client.get('/api/history/jobs', headers={'X-Supra-Account': old_key})
|
| 136 |
+
assert response.status_code == 409
|
| 137 |
+
assert response.headers['x-supra-account-changed'] == '1'
|
| 138 |
+
assert 'items' not in response.json()
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_forged_callback_cannot_replace_an_account_or_issue_a_verified_cookie(env):
|
| 142 |
+
begin(env)
|
| 143 |
+
response = env.client.get('/api/history/auth/callback', params={'state': 'forged', 'code': 'mock-code'})
|
| 144 |
+
assert env.provider.exchanges == 0
|
| 145 |
+
assert not any(v.startswith(COOKIE_NAME+'=') and 'Max-Age=0' not in v for v in response.headers.get_list('set-cookie'))
|
| 146 |
+
assert env.client.get('/api/history/me').json()['authenticated'] is False
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_refresh_requires_a_bounded_fresh_client_check(env):
|
| 150 |
+
response = env.client.get('/api/history/auth/start?refresh=1&silent=1', follow_redirects=False)
|
| 151 |
+
assert response.status_code == 400
|
| 152 |
+
assert env.provider.params is None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def test_silent_check_works_when_the_unpartitioned_gradio_cookie_is_blocked(env):
|
| 156 |
+
begin(env)
|
| 157 |
+
env.client.cookies.delete('session', domain='app.example', path='/')
|
| 158 |
+
response = finish(env)
|
| 159 |
+
assert response.status_code == 200
|
| 160 |
+
identity = env.client.get('/api/history/me').json()
|
| 161 |
+
assert identity['username'] == 'bob'
|
| 162 |
+
assert identity['verification_key'] == 'c'*43
|
| 163 |
+
assert any('__Host-supra-history-oauth-check=;' in c and 'Max-Age=0' in c
|
| 164 |
+
for c in response.headers.get_list('set-cookie'))
|
tests/test_history_ui_resume.cjs
CHANGED
|
@@ -18,6 +18,79 @@ const deployment = (runtimeId, completed = 2, total = 5) => ({
|
|
| 18 |
models: {stage: 'loading_models', startup_progress: {completed, total, unit: 'models', label: 'Internal model name'}}
|
| 19 |
});
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
function fakeDOM() {
|
| 22 |
const nodes = new Map();
|
| 23 |
class Element {
|
|
@@ -29,7 +102,9 @@ function fakeDOM() {
|
|
| 29 |
}
|
| 30 |
get textContent() { return this._textContent; }
|
| 31 |
set textContent(value) { this._textContent = value; this.textHistory.push(value); }
|
| 32 |
-
close() { this.hidden = true; }
|
|
|
|
|
|
|
| 33 |
addEventListener(type, handler) {
|
| 34 |
const handlers = this.handlers.get(type) || [];
|
| 35 |
handlers.push(handler); this.handlers.set(type, handlers);
|
|
@@ -69,7 +144,7 @@ function fakeDOM() {
|
|
| 69 |
nodes.get('st-job-progress').append(new Element('progress'));
|
| 70 |
nodes.get('st-texture-progress').append(new Element('progress'));
|
| 71 |
const documentHandlers = new Map();
|
| 72 |
-
const document = {hidden: false, getElementById: id => nodes.get(id) || null, handlers: documentHandlers,
|
| 73 |
createElement: tag => new Element(tag), createDocumentFragment: () => new Element('#fragment'),
|
| 74 |
addEventListener(type, handler) {
|
| 75 |
const handlers = documentHandlers.get(type) || [];
|
|
@@ -95,6 +170,7 @@ function setup(t) {
|
|
| 95 |
const context = vm.createContext({
|
| 96 |
AbortController, AbortSignal, DOMException, Headers, URL: UploadURL, URLSearchParams, Image: UploadImage, crypto: require('node:crypto').webcrypto,
|
| 97 |
document: dom.document, window: {addEventListener() {}},
|
|
|
|
| 98 |
location: {origin: 'https://example.hf.space', href: 'https://example.hf.space/'},
|
| 99 |
matchMedia: () => ({matches: false}),
|
| 100 |
requestAnimationFrame: callback => callback(),
|
|
@@ -102,6 +178,10 @@ function setup(t) {
|
|
| 102 |
clearTimeout: id => timers.delete(id),
|
| 103 |
async fetch(url, options = {}) {
|
| 104 |
requests.push({url, method: options.method || 'GET', headers: options.headers, body: options.body});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
if (url === '/api/history/status') {
|
| 106 |
if (network.nextStatus) {
|
| 107 |
const pending = network.nextStatus; network.nextStatus = null; return pending.promise;
|
|
@@ -118,7 +198,8 @@ function setup(t) {
|
|
| 118 |
}
|
| 119 |
if (/^\/api\/history\/uploads\/[^/]+\/foreground$/.test(url)) return response(network.retryUploadResponse);
|
| 120 |
if (url === '/api/history/gpu/prepare') return response({});
|
| 121 |
-
if (url.startsWith('/api/history/jobs?') && network.jobsResponse)
|
|
|
|
| 122 |
if (url === '/api/history/submit' && network.nextSubmission) {
|
| 123 |
const pending = network.nextSubmission; network.nextSubmission = null; return pending.promise;
|
| 124 |
}
|
|
@@ -145,7 +226,7 @@ function setup(t) {
|
|
| 145 |
})};
|
| 146 |
assert.equal((uiSource.match(/void boot\(\);/g) || []).length, 1);
|
| 147 |
vm.runInContext(uiSource.replace('void boot();',
|
| 148 |
-
'globalThis.testUI = {S, renderSelected, refreshStatus, refreshJobs, updateGate, submit, gpuDispatcher, resumeGPU, selectJob, selectExample, previewKey, historyStatus, applyUploadResult, refreshUpload, clearPrivate, clearUploadPreview, retryUploadPreview};'), context);
|
| 149 |
const ui = context.testUI;
|
| 150 |
Object.assign(ui.S, {identity: {authenticated: true, csrf_token: 'csrf-test'}, authChecking: false,
|
| 151 |
selected: {kind: 'job', id: savedJob.id}, source: {upload_id: 'saved-upload'},
|
|
@@ -156,7 +237,7 @@ function setup(t) {
|
|
| 156 |
t.after(async () => {
|
| 157 |
ui.S.stopped = true; ui.gpuDispatcher.reset(); finishGPU.resolve(); await tick();
|
| 158 |
});
|
| 159 |
-
return {...ui, ...dom, network, requests, submissions, response, images, timers,
|
| 160 |
prepareRequests: () => requests.filter(request => request.url === '/api/history/gpu/prepare')};
|
| 161 |
}
|
| 162 |
|
|
|
|
| 18 |
models: {stage: 'loading_models', startup_progress: {completed, total, unit: 'models', label: 'Internal model name'}}
|
| 19 |
});
|
| 20 |
|
| 21 |
+
test('an old app login is hidden and never loads history before the current HF account is verified', async t => {
|
| 22 |
+
const env = setup(t);
|
| 23 |
+
env.S.identity = {authenticated: true, username: 'alice', account_key: 'account-a'};
|
| 24 |
+
env.network.identity = {...env.S.identity, silent_login_url: '/api/history/auth/start?silent=1'};
|
| 25 |
+
env.beginAccountVerification();
|
| 26 |
+
const identity = await env.refreshIdentity();
|
| 27 |
+
assert.equal(identity.authenticated, false);
|
| 28 |
+
assert.equal(env.get('st-workspace').dataset.accountChecking, 'true');
|
| 29 |
+
assert.equal(env.get('st-workspace').inert, true);
|
| 30 |
+
assert.equal(env.requests.filter(r => r.url.startsWith('/api/history/jobs')).length, 0);
|
| 31 |
+
assert.equal(env.submissions.length, 0);
|
| 32 |
+
assert.equal(env.S.jobs.has(savedJob.id), true, 'checking identity does not destroy an ongoing task');
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
test('fresh HF account B replaces account A and clears A history, inputs and cached assets before display', async t => {
|
| 36 |
+
const env = setup(t), accounts = [];
|
| 37 |
+
env.context.window.SupraPrivateAssets = {setAccount: value => accounts.push(value)};
|
| 38 |
+
env.S.identity = {authenticated: true, username: 'alice', account_key: 'account-a'};
|
| 39 |
+
env.S.localURL = 'blob:alice-input';
|
| 40 |
+
env.beginAccountVerification();
|
| 41 |
+
env.network.identity = {authenticated: true, username: 'bob', account_key: 'account-b', verification_key: env.S.accountCheck};
|
| 42 |
+
env.network.jobsResponse = {items: [{...savedJob, id: 'bob-job', status: 'complete'}], revision: 'b:1'};
|
| 43 |
+
await env.finishSilentAuth(true); await tick();
|
| 44 |
+
assert.equal(env.S.identity.username, 'bob');
|
| 45 |
+
assert.equal(env.S.jobs.has(savedJob.id), false);
|
| 46 |
+
assert.equal(env.S.jobs.has('bob-job'), true);
|
| 47 |
+
assert.equal(env.S.source, null);
|
| 48 |
+
assert(env.images.revoked.includes('blob:alice-input'));
|
| 49 |
+
assert(accounts.includes(''));
|
| 50 |
+
assert.equal(accounts.at(-1), 'account-b');
|
| 51 |
+
assert.equal(env.get('st-workspace').dataset.accountChecking, 'false');
|
| 52 |
+
assert.equal(env.submissions.length, 0);
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
test('a silent timeout or blocked cookie cannot fall back to account A history', async t => {
|
| 56 |
+
for (const authenticated of [false, true]) {
|
| 57 |
+
const env = setup(t);
|
| 58 |
+
env.S.identity = {authenticated: true, username: 'alice', account_key: 'account-a'};
|
| 59 |
+
env.beginAccountVerification();
|
| 60 |
+
env.network.identity = {...env.S.identity, verification_key: 'old-check'};
|
| 61 |
+
await env.finishSilentAuth(authenticated);
|
| 62 |
+
assert.equal(env.S.identity.authenticated, false);
|
| 63 |
+
assert.equal(env.S.jobs.size, 0);
|
| 64 |
+
assert.equal(env.S.source, null);
|
| 65 |
+
assert.equal(env.requests.filter(r => r.url.startsWith('/api/history/jobs')).length, 0);
|
| 66 |
+
assert.equal(env.get('st-auth-dialog').open, true);
|
| 67 |
+
}
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
test('leaving the page masks private content while keeping an active operation intact', async t => {
|
| 71 |
+
const env = setup(t);
|
| 72 |
+
env.S.accountNeedsCheck = false;
|
| 73 |
+
env.shieldPrivateWorkspace();
|
| 74 |
+
assert.equal(env.S.accountNeedsCheck, true);
|
| 75 |
+
assert.equal(env.get('st-workspace').dataset.accountChecking, 'true');
|
| 76 |
+
assert.equal(env.get('st-workspace').inert, true);
|
| 77 |
+
assert.equal(env.S.jobs.has(savedJob.id), true);
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
test('a cookie changed by another tab cannot merge its history into the old account', async t => {
|
| 81 |
+
const env = setup(t);
|
| 82 |
+
env.S.identity = {authenticated: true, username: 'alice', account_key: 'account-a', silent_login_url: '/api/history/auth/start?silent=1'};
|
| 83 |
+
env.network.jobsResponse = {detail: 'Your account changed.'};
|
| 84 |
+
env.network.jobsStatus = 409; env.network.jobsHeaders = {'X-Supra-Account-Changed': '1'};
|
| 85 |
+
await env.refreshJobs();
|
| 86 |
+
const request = env.requests.find(r => r.url.startsWith('/api/history/jobs'));
|
| 87 |
+
assert.equal(request.headers['X-Supra-Account'], 'account-a');
|
| 88 |
+
assert.equal(env.S.jobs.size, 0);
|
| 89 |
+
assert.equal(env.S.verifyRequired, true);
|
| 90 |
+
assert.equal(env.get('st-workspace').dataset.accountChecking, 'true');
|
| 91 |
+
assert.equal(env.submissions.length, 0);
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
function fakeDOM() {
|
| 95 |
const nodes = new Map();
|
| 96 |
class Element {
|
|
|
|
| 102 |
}
|
| 103 |
get textContent() { return this._textContent; }
|
| 104 |
set textContent(value) { this._textContent = value; this.textHistory.push(value); }
|
| 105 |
+
close() { this.hidden = true; this.open = false; }
|
| 106 |
+
showModal() { this.hidden = false; this.open = true; }
|
| 107 |
+
remove() { this.removed = true; }
|
| 108 |
addEventListener(type, handler) {
|
| 109 |
const handlers = this.handlers.get(type) || [];
|
| 110 |
handlers.push(handler); this.handlers.set(type, handlers);
|
|
|
|
| 144 |
nodes.get('st-job-progress').append(new Element('progress'));
|
| 145 |
nodes.get('st-texture-progress').append(new Element('progress'));
|
| 146 |
const documentHandlers = new Map();
|
| 147 |
+
const document = {hidden: false, body: new Element('body'), getElementById: id => nodes.get(id) || null, handlers: documentHandlers,
|
| 148 |
createElement: tag => new Element(tag), createDocumentFragment: () => new Element('#fragment'),
|
| 149 |
addEventListener(type, handler) {
|
| 150 |
const handlers = documentHandlers.get(type) || [];
|
|
|
|
| 170 |
const context = vm.createContext({
|
| 171 |
AbortController, AbortSignal, DOMException, Headers, URL: UploadURL, URLSearchParams, Image: UploadImage, crypto: require('node:crypto').webcrypto,
|
| 172 |
document: dom.document, window: {addEventListener() {}},
|
| 173 |
+
btoa: value => Buffer.from(value, 'binary').toString('base64'),
|
| 174 |
location: {origin: 'https://example.hf.space', href: 'https://example.hf.space/'},
|
| 175 |
matchMedia: () => ({matches: false}),
|
| 176 |
requestAnimationFrame: callback => callback(),
|
|
|
|
| 178 |
clearTimeout: id => timers.delete(id),
|
| 179 |
async fetch(url, options = {}) {
|
| 180 |
requests.push({url, method: options.method || 'GET', headers: options.headers, body: options.body});
|
| 181 |
+
if (url === '/api/history/me') {
|
| 182 |
+
if (network.nextIdentity) { const pending = network.nextIdentity; network.nextIdentity = null; return pending.promise; }
|
| 183 |
+
return response(network.identity);
|
| 184 |
+
}
|
| 185 |
if (url === '/api/history/status') {
|
| 186 |
if (network.nextStatus) {
|
| 187 |
const pending = network.nextStatus; network.nextStatus = null; return pending.promise;
|
|
|
|
| 198 |
}
|
| 199 |
if (/^\/api\/history\/uploads\/[^/]+\/foreground$/.test(url)) return response(network.retryUploadResponse);
|
| 200 |
if (url === '/api/history/gpu/prepare') return response({});
|
| 201 |
+
if (url.startsWith('/api/history/jobs?') && network.jobsResponse)
|
| 202 |
+
return new Response(JSON.stringify(network.jobsResponse), {status: network.jobsStatus || 200, headers: network.jobsHeaders});
|
| 203 |
if (url === '/api/history/submit' && network.nextSubmission) {
|
| 204 |
const pending = network.nextSubmission; network.nextSubmission = null; return pending.promise;
|
| 205 |
}
|
|
|
|
| 226 |
})};
|
| 227 |
assert.equal((uiSource.match(/void boot\(\);/g) || []).length, 1);
|
| 228 |
vm.runInContext(uiSource.replace('void boot();',
|
| 229 |
+
'globalThis.testUI = {S, renderSelected, refreshStatus, refreshJobs, updateGate, submit, gpuDispatcher, resumeGPU, selectJob, selectExample, previewKey, historyStatus, applyUploadResult, refreshUpload, clearPrivate, clearUploadPreview, retryUploadPreview, refreshIdentity, beginAccountVerification, finishSilentAuth, shieldPrivateWorkspace};'), context);
|
| 230 |
const ui = context.testUI;
|
| 231 |
Object.assign(ui.S, {identity: {authenticated: true, csrf_token: 'csrf-test'}, authChecking: false,
|
| 232 |
selected: {kind: 'job', id: savedJob.id}, source: {upload_id: 'saved-upload'},
|
|
|
|
| 237 |
t.after(async () => {
|
| 238 |
ui.S.stopped = true; ui.gpuDispatcher.reset(); finishGPU.resolve(); await tick();
|
| 239 |
});
|
| 240 |
+
return {...ui, ...dom, network, requests, submissions, response, images, timers, context,
|
| 241 |
prepareRequests: () => requests.filter(request => request.url === '/api/history/gpu/prepare')};
|
| 242 |
}
|
| 243 |
|