Merge remote-tracking branch 'hf/main'
Browse files- pyproject.toml +10 -0
- src/mosaic/analysis.py +2 -1
- src/mosaic/telemetry/__init__.py +0 -2
- src/mosaic/telemetry/tracker.py +3 -4
- src/mosaic/telemetry/utils.py +42 -26
- src/mosaic/ui/app.py +204 -83
- src/mosaic/ui/oauth.py +408 -0
- src/mosaic/ui/user_tabs.py +82 -35
- tests/telemetry/test_tracker.py +2 -3
- tests/telemetry/test_utils.py +0 -35
- tests/test_oauth.py +479 -0
- tests/test_ui_user_storage.py +17 -10
- uv.lock +0 -0
pyproject.toml
CHANGED
|
@@ -42,6 +42,16 @@ disable = [
|
|
| 42 |
"unspecified-encoding",
|
| 43 |
]
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
[tool.uv.sources]
|
| 46 |
# For local dev with SSH: uv pip install -e .
|
| 47 |
# For Docker builds with token: GH_TOKEN=<token> uv pip install -e .
|
|
|
|
| 42 |
"unspecified-encoding",
|
| 43 |
]
|
| 44 |
|
| 45 |
+
[tool.uv]
|
| 46 |
+
# Override PyTorch version constraints from mussel[torch-gpu].
|
| 47 |
+
# mussel pins torch<2.6 which may be too strict; we relax to >=2.0.0.
|
| 48 |
+
# The override applies on ALL platforms (not just macOS) so Docker builds
|
| 49 |
+
# still get torch installed.
|
| 50 |
+
override-dependencies = [
|
| 51 |
+
"torch>=2.0.0",
|
| 52 |
+
"torchvision>=0.15.0",
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
[tool.uv.sources]
|
| 56 |
# For local dev with SSH: uv pip install -e .
|
| 57 |
# For Docker builds with token: GH_TOKEN=<token> uv pip install -e .
|
src/mosaic/analysis.py
CHANGED
|
@@ -772,6 +772,7 @@ def analyze_slide(
|
|
| 772 |
analysis_id=None,
|
| 773 |
session_hash=None,
|
| 774 |
profile=None,
|
|
|
|
| 775 |
):
|
| 776 |
"""Analyze a whole slide image for cancer subtype and biomarker prediction.
|
| 777 |
|
|
@@ -810,7 +811,7 @@ def analyze_slide(
|
|
| 810 |
raise gr.Error("Please upload a slide.")
|
| 811 |
|
| 812 |
# Extract user info for telemetry (HF Spaces only)
|
| 813 |
-
user_info = extract_user_info(request, IS_HF_SPACES, profile)
|
| 814 |
|
| 815 |
# Initialize telemetry for resource tracking
|
| 816 |
slide_start_time = time_module.time()
|
|
|
|
| 772 |
analysis_id=None,
|
| 773 |
session_hash=None,
|
| 774 |
profile=None,
|
| 775 |
+
session_id: str = "",
|
| 776 |
):
|
| 777 |
"""Analyze a whole slide image for cancer subtype and biomarker prediction.
|
| 778 |
|
|
|
|
| 811 |
raise gr.Error("Please upload a slide.")
|
| 812 |
|
| 813 |
# Extract user info for telemetry (HF Spaces only)
|
| 814 |
+
user_info = extract_user_info(request, IS_HF_SPACES, profile, session_id)
|
| 815 |
|
| 816 |
# Initialize telemetry for resource tracking
|
| 817 |
slide_start_time = time_module.time()
|
src/mosaic/telemetry/__init__.py
CHANGED
|
@@ -47,7 +47,6 @@ from mosaic.telemetry.utils import (
|
|
| 47 |
StageTimer,
|
| 48 |
sanitize_error_message,
|
| 49 |
hash_session_id,
|
| 50 |
-
hash_username,
|
| 51 |
UserInfo,
|
| 52 |
extract_user_info,
|
| 53 |
)
|
|
@@ -68,7 +67,6 @@ __all__ = [
|
|
| 68 |
"StageTimer",
|
| 69 |
"sanitize_error_message",
|
| 70 |
"hash_session_id",
|
| 71 |
-
"hash_username",
|
| 72 |
"UserInfo",
|
| 73 |
"extract_user_info",
|
| 74 |
]
|
|
|
|
| 47 |
StageTimer,
|
| 48 |
sanitize_error_message,
|
| 49 |
hash_session_id,
|
|
|
|
| 50 |
UserInfo,
|
| 51 |
extract_user_info,
|
| 52 |
)
|
|
|
|
| 67 |
"StageTimer",
|
| 68 |
"sanitize_error_message",
|
| 69 |
"hash_session_id",
|
|
|
|
| 70 |
"UserInfo",
|
| 71 |
"extract_user_info",
|
| 72 |
]
|
src/mosaic/telemetry/tracker.py
CHANGED
|
@@ -22,7 +22,6 @@ from mosaic.telemetry.events import (
|
|
| 22 |
from mosaic.telemetry.storage import TelemetryStorage
|
| 23 |
from mosaic.telemetry.utils import (
|
| 24 |
hash_session_id,
|
| 25 |
-
hash_username,
|
| 26 |
sanitize_error_message,
|
| 27 |
)
|
| 28 |
|
|
@@ -256,7 +255,7 @@ class TelemetryTracker:
|
|
| 256 |
success=success,
|
| 257 |
cached_slide_count=cached_slide_count,
|
| 258 |
is_logged_in=is_logged_in,
|
| 259 |
-
hf_username=
|
| 260 |
)
|
| 261 |
self.storage.write_usage_event(event)
|
| 262 |
|
|
@@ -332,7 +331,7 @@ class TelemetryTracker:
|
|
| 332 |
gpu_type=gpu_type,
|
| 333 |
peak_gpu_memory_gb=peak_gpu_memory_gb,
|
| 334 |
is_logged_in=is_logged_in,
|
| 335 |
-
hf_username=
|
| 336 |
)
|
| 337 |
self.storage.write_resource_event(event)
|
| 338 |
|
|
@@ -377,7 +376,7 @@ class TelemetryTracker:
|
|
| 377 |
slide_count=slide_count,
|
| 378 |
gpu_type=gpu_type,
|
| 379 |
is_logged_in=is_logged_in,
|
| 380 |
-
hf_username=
|
| 381 |
)
|
| 382 |
self.storage.write_failure_event(event)
|
| 383 |
|
|
|
|
| 22 |
from mosaic.telemetry.storage import TelemetryStorage
|
| 23 |
from mosaic.telemetry.utils import (
|
| 24 |
hash_session_id,
|
|
|
|
| 25 |
sanitize_error_message,
|
| 26 |
)
|
| 27 |
|
|
|
|
| 255 |
success=success,
|
| 256 |
cached_slide_count=cached_slide_count,
|
| 257 |
is_logged_in=is_logged_in,
|
| 258 |
+
hf_username=hf_username,
|
| 259 |
)
|
| 260 |
self.storage.write_usage_event(event)
|
| 261 |
|
|
|
|
| 331 |
gpu_type=gpu_type,
|
| 332 |
peak_gpu_memory_gb=peak_gpu_memory_gb,
|
| 333 |
is_logged_in=is_logged_in,
|
| 334 |
+
hf_username=hf_username,
|
| 335 |
)
|
| 336 |
self.storage.write_resource_event(event)
|
| 337 |
|
|
|
|
| 376 |
slide_count=slide_count,
|
| 377 |
gpu_type=gpu_type,
|
| 378 |
is_logged_in=is_logged_in,
|
| 379 |
+
hf_username=hf_username,
|
| 380 |
)
|
| 381 |
self.storage.write_failure_event(event)
|
| 382 |
|
src/mosaic/telemetry/utils.py
CHANGED
|
@@ -5,7 +5,7 @@ This module provides helper utilities:
|
|
| 5 |
- sanitize_error_message: Remove sensitive data from error messages
|
| 6 |
- hash_session_id: Hash session IDs for privacy
|
| 7 |
- UserInfo: Dataclass for user information from HF Spaces
|
| 8 |
-
- extract_user_info: Extract user info from Gradio request
|
| 9 |
"""
|
| 10 |
|
| 11 |
import hashlib
|
|
@@ -99,25 +99,6 @@ def hash_session_id(session_id: Optional[str]) -> Optional[str]:
|
|
| 99 |
return hashlib.sha256(salted.encode()).hexdigest()[:16]
|
| 100 |
|
| 101 |
|
| 102 |
-
def hash_username(username: Optional[str]) -> Optional[str]:
|
| 103 |
-
"""Hash a username for privacy in telemetry.
|
| 104 |
-
|
| 105 |
-
Uses SHA-256 with a different salt than session IDs to create a one-way hash.
|
| 106 |
-
This allows distinguishing users in telemetry without storing actual usernames.
|
| 107 |
-
|
| 108 |
-
Args:
|
| 109 |
-
username: HuggingFace username (can be None for anonymous users)
|
| 110 |
-
|
| 111 |
-
Returns:
|
| 112 |
-
Hashed username or None if input is None
|
| 113 |
-
"""
|
| 114 |
-
if username is None:
|
| 115 |
-
return None
|
| 116 |
-
|
| 117 |
-
salted = f"mosaic_user:{username}"
|
| 118 |
-
return hashlib.sha256(salted.encode()).hexdigest()[:16]
|
| 119 |
-
|
| 120 |
-
|
| 121 |
@dataclass
|
| 122 |
class UserInfo:
|
| 123 |
"""User information extracted from HF Spaces request.
|
|
@@ -131,7 +112,12 @@ class UserInfo:
|
|
| 131 |
username: Optional[str] = None
|
| 132 |
|
| 133 |
|
| 134 |
-
def extract_user_info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
"""Extract user info from Gradio OAuth profile.
|
| 136 |
|
| 137 |
With gr.LoginButton(), Gradio provides user info via gr.OAuthProfile
|
|
@@ -145,21 +131,51 @@ def extract_user_info(request, is_hf_spaces: bool = False, profile=None) -> User
|
|
| 145 |
request: Gradio request object (unused, kept for API compatibility)
|
| 146 |
is_hf_spaces: Whether running on HuggingFace Spaces (only extract on HF)
|
| 147 |
profile: Gradio OAuthProfile object (injected by LoginButton OAuth flow)
|
|
|
|
|
|
|
| 148 |
|
| 149 |
Returns:
|
| 150 |
UserInfo with is_logged_in and username (or defaults if not available)
|
| 151 |
|
| 152 |
Example:
|
| 153 |
-
user_info = extract_user_info(request, IS_HF_SPACES, profile)
|
| 154 |
if user_info.is_logged_in:
|
| 155 |
print(f"User: {user_info.username}")
|
| 156 |
"""
|
| 157 |
if not is_hf_spaces:
|
| 158 |
return UserInfo()
|
| 159 |
|
| 160 |
-
# On HF Spaces
|
| 161 |
-
#
|
| 162 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
if profile is not None:
|
| 164 |
try:
|
| 165 |
username = getattr(profile, "username", None)
|
|
@@ -169,5 +185,5 @@ def extract_user_info(request, is_hf_spaces: bool = False, profile=None) -> User
|
|
| 169 |
except Exception as e:
|
| 170 |
logger.debug(f"Could not extract username from OAuthProfile: {e}")
|
| 171 |
|
| 172 |
-
logger.debug("User not logged in: no OAuthProfile available")
|
| 173 |
return UserInfo()
|
|
|
|
| 5 |
- sanitize_error_message: Remove sensitive data from error messages
|
| 6 |
- hash_session_id: Hash session IDs for privacy
|
| 7 |
- UserInfo: Dataclass for user information from HF Spaces
|
| 8 |
+
- extract_user_info: Extract user info from Gradio request/OAuth profile
|
| 9 |
"""
|
| 10 |
|
| 11 |
import hashlib
|
|
|
|
| 99 |
return hashlib.sha256(salted.encode()).hexdigest()[:16]
|
| 100 |
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
@dataclass
|
| 103 |
class UserInfo:
|
| 104 |
"""User information extracted from HF Spaces request.
|
|
|
|
| 112 |
username: Optional[str] = None
|
| 113 |
|
| 114 |
|
| 115 |
+
def extract_user_info(
|
| 116 |
+
request,
|
| 117 |
+
is_hf_spaces: bool = False,
|
| 118 |
+
profile=None,
|
| 119 |
+
session_id: Optional[str] = None,
|
| 120 |
+
) -> UserInfo:
|
| 121 |
"""Extract user info from Gradio OAuth profile.
|
| 122 |
|
| 123 |
With gr.LoginButton(), Gradio provides user info via gr.OAuthProfile
|
|
|
|
| 131 |
request: Gradio request object (unused, kept for API compatibility)
|
| 132 |
is_hf_spaces: Whether running on HuggingFace Spaces (only extract on HF)
|
| 133 |
profile: Gradio OAuthProfile object (injected by LoginButton OAuth flow)
|
| 134 |
+
session_id: Session ID from localStorage (primary lookup on HF Spaces
|
| 135 |
+
where the HF proxy strips custom cookies)
|
| 136 |
|
| 137 |
Returns:
|
| 138 |
UserInfo with is_logged_in and username (or defaults if not available)
|
| 139 |
|
| 140 |
Example:
|
| 141 |
+
user_info = extract_user_info(request, IS_HF_SPACES, profile, session_id)
|
| 142 |
if user_info.is_logged_in:
|
| 143 |
print(f"User: {user_info.username}")
|
| 144 |
"""
|
| 145 |
if not is_hf_spaces:
|
| 146 |
return UserInfo()
|
| 147 |
|
| 148 |
+
# On HF Spaces with Docker SDK, the HF proxy strips custom cookies.
|
| 149 |
+
# The most reliable method is looking up the session ID that was stored
|
| 150 |
+
# in localStorage by the OAuth callback page.
|
| 151 |
+
if session_id:
|
| 152 |
+
try:
|
| 153 |
+
from mosaic.ui.oauth import get_user_by_session_id
|
| 154 |
+
|
| 155 |
+
userinfo = get_user_by_session_id(session_id)
|
| 156 |
+
if userinfo:
|
| 157 |
+
username = userinfo.get("preferred_username")
|
| 158 |
+
if username:
|
| 159 |
+
logger.info(f"Extracted user from session ID: {username}")
|
| 160 |
+
return UserInfo(is_logged_in=True, username=username)
|
| 161 |
+
except Exception as e:
|
| 162 |
+
logger.debug(f"Session ID lookup failed: {e}")
|
| 163 |
+
|
| 164 |
+
# Fallback: server-side session via mosaic_auth cookie
|
| 165 |
+
if request is not None:
|
| 166 |
+
try:
|
| 167 |
+
from mosaic.ui.oauth import get_user_from_server_session
|
| 168 |
+
|
| 169 |
+
userinfo = get_user_from_server_session(request)
|
| 170 |
+
if userinfo:
|
| 171 |
+
username = userinfo.get("preferred_username")
|
| 172 |
+
if username:
|
| 173 |
+
logger.info(f"Extracted user from server-side session: {username}")
|
| 174 |
+
return UserInfo(is_logged_in=True, username=username)
|
| 175 |
+
except Exception as e:
|
| 176 |
+
logger.debug(f"Server-side session lookup failed: {e}")
|
| 177 |
+
|
| 178 |
+
# Fallback: OAuthProfile (may be unreliable on Docker SDK Spaces)
|
| 179 |
if profile is not None:
|
| 180 |
try:
|
| 181 |
username = getattr(profile, "username", None)
|
|
|
|
| 185 |
except Exception as e:
|
| 186 |
logger.debug(f"Could not extract username from OAuthProfile: {e}")
|
| 187 |
|
| 188 |
+
logger.debug("User not logged in: no session or OAuthProfile available")
|
| 189 |
return UserInfo()
|
src/mosaic/ui/app.py
CHANGED
|
@@ -190,6 +190,7 @@ def analyze_slides(
|
|
| 190 |
seg_config,
|
| 191 |
user_dir,
|
| 192 |
slide_ids=None, # Mapping from filename to slide_id
|
|
|
|
| 193 |
progress=gr.Progress(track_tqdm=True),
|
| 194 |
request: gr.Request = None,
|
| 195 |
profile: Optional[gr.OAuthProfile] = None,
|
|
@@ -228,7 +229,7 @@ def analyze_slides(
|
|
| 228 |
|
| 229 |
# Extract user info for telemetry (HF Spaces only)
|
| 230 |
# gr.LoginButton() OAuth provides user info via OAuthProfile
|
| 231 |
-
user_info = extract_user_info(request, IS_HF_SPACES, profile)
|
| 232 |
|
| 233 |
# Wait for core models download to complete (Paladin models can continue in background)
|
| 234 |
if _model_download_thread is not None and not _core_models_complete:
|
|
@@ -472,6 +473,7 @@ def analyze_slides(
|
|
| 472 |
analysis_id=analysis_id, # For telemetry tracking
|
| 473 |
session_hash=session_hash, # For telemetry tracking
|
| 474 |
profile=profile, # For OAuth user info
|
|
|
|
| 475 |
)
|
| 476 |
|
| 477 |
# Save results to cache if this is a TCGA slide
|
|
@@ -759,8 +761,18 @@ def launch_gradio(server_name, server_port, share):
|
|
| 759 |
# stays permanently hidden.
|
| 760 |
login_button.load_event_to_attach = None
|
| 761 |
|
| 762 |
-
# Custom login display
|
| 763 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 764 |
|
| 765 |
gr.Markdown(
|
| 766 |
"# Mosaic: H&E Whole Slide Image Cancer Subtype and Biomarker Inference"
|
|
@@ -1211,9 +1223,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1211 |
gr.Group(visible=False),
|
| 1212 |
gr.Dataframe(visible=False),
|
| 1213 |
gr.File(visible=False),
|
| 1214 |
-
gr.Row(
|
| 1215 |
-
visible=not IS_HF_SPACES
|
| 1216 |
-
), # Show existing files row in upload mode
|
| 1217 |
)
|
| 1218 |
else:
|
| 1219 |
return (
|
|
@@ -1226,13 +1236,14 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1226 |
|
| 1227 |
# Load storage usage and show warnings
|
| 1228 |
def load_storage_usage(
|
|
|
|
| 1229 |
request: gr.Request = None,
|
| 1230 |
profile: Optional[gr.OAuthProfile] = None,
|
| 1231 |
):
|
| 1232 |
"""Load and display storage usage with warnings."""
|
| 1233 |
from mosaic.ui.user_tabs import _get_username
|
| 1234 |
|
| 1235 |
-
username, is_local = _get_username(request, profile)
|
| 1236 |
|
| 1237 |
if not username:
|
| 1238 |
# Not logged in on HF Spaces - hide storage info
|
|
@@ -1282,13 +1293,14 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1282 |
|
| 1283 |
# Load list of existing slides for re-analysis
|
| 1284 |
def load_existing_slides(
|
|
|
|
| 1285 |
request: gr.Request = None,
|
| 1286 |
profile: Optional[gr.OAuthProfile] = None,
|
| 1287 |
):
|
| 1288 |
"""Load dropdown with user's existing slides."""
|
| 1289 |
from mosaic.ui.user_tabs import _get_username
|
| 1290 |
|
| 1291 |
-
username, _ = _get_username(request, profile)
|
| 1292 |
|
| 1293 |
if not username:
|
| 1294 |
return gr.Dropdown(choices=[], value=None)
|
|
@@ -1326,6 +1338,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1326 |
def handle_slide_upload(
|
| 1327 |
files,
|
| 1328 |
slide_ids,
|
|
|
|
| 1329 |
request: gr.Request = None,
|
| 1330 |
profile: Optional[gr.OAuthProfile] = None,
|
| 1331 |
):
|
|
@@ -1336,7 +1349,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1336 |
# Determine username via OAuthProfile (primary) or request (fallback)
|
| 1337 |
from mosaic.ui.user_tabs import _get_username
|
| 1338 |
|
| 1339 |
-
username, _ = _get_username(request, profile)
|
| 1340 |
if not username:
|
| 1341 |
return files, slide_ids or {}
|
| 1342 |
|
|
@@ -1500,34 +1513,47 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1500 |
],
|
| 1501 |
)
|
| 1502 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1503 |
# Handle file uploads - regenerate entire settings table
|
| 1504 |
# Save uploaded slides to user storage (HF Spaces only)
|
| 1505 |
input_slides.upload(
|
| 1506 |
handle_slide_upload,
|
| 1507 |
-
inputs=[input_slides, slide_ids_state],
|
| 1508 |
outputs=[input_slides, slide_ids_state],
|
| 1509 |
).then(
|
| 1510 |
# Refresh storage usage after upload
|
| 1511 |
load_storage_usage,
|
| 1512 |
-
inputs=
|
| 1513 |
outputs=[storage_usage_warning, existing_file_row],
|
| 1514 |
).then(
|
| 1515 |
# Refresh existing slides list after upload
|
| 1516 |
load_existing_slides,
|
| 1517 |
-
inputs=
|
| 1518 |
outputs=[existing_slides_dropdown],
|
| 1519 |
)
|
| 1520 |
|
| 1521 |
# Refresh existing slides dropdown
|
| 1522 |
refresh_existing_btn.click(
|
| 1523 |
load_existing_slides,
|
| 1524 |
-
inputs=
|
| 1525 |
outputs=[existing_slides_dropdown],
|
| 1526 |
)
|
| 1527 |
|
| 1528 |
# Handle selection of existing slide(s) for re-analysis
|
| 1529 |
def select_existing_slide(
|
| 1530 |
slide_ids,
|
|
|
|
| 1531 |
request: gr.Request = None,
|
| 1532 |
profile: Optional[gr.OAuthProfile] = None,
|
| 1533 |
):
|
|
@@ -1535,6 +1561,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1535 |
|
| 1536 |
Args:
|
| 1537 |
slide_ids: Single slide_id (str) or list of slide_ids (multiselect)
|
|
|
|
| 1538 |
request: Gradio request object
|
| 1539 |
profile: OAuthProfile for HF Spaces login
|
| 1540 |
|
|
@@ -1550,7 +1577,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1550 |
|
| 1551 |
from mosaic.ui.user_tabs import _get_username
|
| 1552 |
|
| 1553 |
-
username, _ = _get_username(request, profile)
|
| 1554 |
if not username:
|
| 1555 |
return None
|
| 1556 |
|
|
@@ -1593,7 +1620,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1593 |
|
| 1594 |
existing_slides_dropdown.change(
|
| 1595 |
select_existing_slide,
|
| 1596 |
-
inputs=[existing_slides_dropdown],
|
| 1597 |
outputs=[input_slides],
|
| 1598 |
)
|
| 1599 |
|
|
@@ -1732,6 +1759,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1732 |
seg_config,
|
| 1733 |
user_dir,
|
| 1734 |
slide_ids,
|
|
|
|
| 1735 |
progress=gr.Progress(track_tqdm=True),
|
| 1736 |
request: gr.Request = None,
|
| 1737 |
profile: Optional[gr.OAuthProfile] = None,
|
|
@@ -1777,6 +1805,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1777 |
seg_config,
|
| 1778 |
user_dir,
|
| 1779 |
slide_ids=slide_ids,
|
|
|
|
| 1780 |
progress=progress,
|
| 1781 |
request=request,
|
| 1782 |
profile=profile,
|
|
@@ -1797,6 +1826,7 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1797 |
seg_config_dropdown,
|
| 1798 |
user_dir_state,
|
| 1799 |
slide_ids_state,
|
|
|
|
| 1800 |
],
|
| 1801 |
outputs=[
|
| 1802 |
settings_input,
|
|
@@ -1863,20 +1893,6 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1863 |
outputs=[user_dir_state],
|
| 1864 |
)
|
| 1865 |
|
| 1866 |
-
# Load storage usage on page load
|
| 1867 |
-
demo.load(
|
| 1868 |
-
load_storage_usage,
|
| 1869 |
-
inputs=None,
|
| 1870 |
-
outputs=[storage_usage_warning, existing_file_row],
|
| 1871 |
-
)
|
| 1872 |
-
|
| 1873 |
-
# Load existing slides dropdown on page load
|
| 1874 |
-
demo.load(
|
| 1875 |
-
load_existing_slides,
|
| 1876 |
-
inputs=None,
|
| 1877 |
-
outputs=[existing_slides_dropdown],
|
| 1878 |
-
)
|
| 1879 |
-
|
| 1880 |
# Add My Files and My Results tabs for user storage
|
| 1881 |
# On HF Spaces: visible for logged-in users
|
| 1882 |
# Locally: always visible with "local_user" for debugging
|
|
@@ -1901,81 +1917,169 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1901 |
with gr.Tab("My Files"):
|
| 1902 |
from mosaic.ui.user_tabs import create_my_files_tab
|
| 1903 |
|
| 1904 |
-
my_files_components = create_my_files_tab()
|
| 1905 |
-
|
| 1906 |
-
# Load files on demo load
|
| 1907 |
-
demo.load(
|
| 1908 |
-
my_files_components["load_files"],
|
| 1909 |
-
inputs=None,
|
| 1910 |
-
outputs=[
|
| 1911 |
-
my_files_components["storage_usage"],
|
| 1912 |
-
my_files_components["files_table"],
|
| 1913 |
-
my_files_components["file_action_status"],
|
| 1914 |
-
my_files_components["slide_download_file"],
|
| 1915 |
-
],
|
| 1916 |
-
)
|
| 1917 |
|
| 1918 |
with gr.Tab("My Results"):
|
| 1919 |
from mosaic.ui.user_tabs import create_my_results_tab
|
| 1920 |
|
| 1921 |
-
my_results_components = create_my_results_tab()
|
| 1922 |
|
| 1923 |
-
|
| 1924 |
-
|
| 1925 |
-
|
| 1926 |
-
|
| 1927 |
-
|
| 1928 |
-
|
| 1929 |
-
|
| 1930 |
-
|
| 1931 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1932 |
)
|
| 1933 |
|
| 1934 |
-
|
| 1935 |
-
|
| 1936 |
-
# is never available at that point. Instead we rely on demo.load()
|
| 1937 |
-
# which fires after the OAuth redirect completes and the page reloads.
|
| 1938 |
-
#
|
| 1939 |
-
# On HF Spaces with sdk:docker, LoginButton is always hidden because
|
| 1940 |
-
# it reads request.username (which returns the Space owner for all
|
| 1941 |
-
# visitors). Instead we use login_status_md with direct OAuth links.
|
| 1942 |
-
if IS_HF_SPACES:
|
| 1943 |
|
| 1944 |
-
|
| 1945 |
-
|
| 1946 |
-
if
|
| 1947 |
-
username =
|
| 1948 |
-
|
| 1949 |
-
)
|
| 1950 |
-
return (
|
| 1951 |
gr.update(
|
| 1952 |
-
value=f"Signed in as **{username}**
|
| 1953 |
visible=True,
|
| 1954 |
-
),
|
| 1955 |
-
gr.update(visible=True),
|
| 1956 |
-
gr.update(visible=True),
|
| 1957 |
-
gr.update(visible=True),
|
|
|
|
| 1958 |
)
|
| 1959 |
else:
|
| 1960 |
-
return (
|
| 1961 |
-
|
| 1962 |
-
|
| 1963 |
-
|
| 1964 |
-
|
| 1965 |
-
|
| 1966 |
-
|
| 1967 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1968 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1969 |
|
| 1970 |
demo.load(
|
| 1971 |
-
|
| 1972 |
-
inputs=
|
| 1973 |
outputs=[
|
|
|
|
| 1974 |
login_status_md,
|
|
|
|
| 1975 |
user_storage_tabs,
|
| 1976 |
user_storage_header,
|
| 1977 |
user_storage_info,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1978 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1979 |
)
|
| 1980 |
|
| 1981 |
# Use hardware-specific concurrency limit
|
|
@@ -1983,6 +2087,13 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 1983 |
# Higher-memory GPUs and ZeroGPU can handle multiple concurrent analyses
|
| 1984 |
demo.queue(max_size=10, default_concurrency_limit=DEFAULT_CONCURRENCY_LIMIT)
|
| 1985 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1986 |
# Register cleanup handler for graceful shutdown
|
| 1987 |
import atexit
|
| 1988 |
|
|
@@ -2014,10 +2125,20 @@ This tool is for research purposes only and not approved for clinical diagnosis.
|
|
| 2014 |
heartbeat_thread = threading.Thread(target=heartbeat_worker, daemon=True)
|
| 2015 |
heartbeat_thread.start()
|
| 2016 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2017 |
demo.launch(
|
| 2018 |
server_name=server_name,
|
| 2019 |
share=share,
|
| 2020 |
server_port=server_port,
|
| 2021 |
show_error=True,
|
| 2022 |
favicon_path=current_dir / "favicon.svg",
|
|
|
|
| 2023 |
)
|
|
|
|
| 190 |
seg_config,
|
| 191 |
user_dir,
|
| 192 |
slide_ids=None, # Mapping from filename to slide_id
|
| 193 |
+
session_id: str = "",
|
| 194 |
progress=gr.Progress(track_tqdm=True),
|
| 195 |
request: gr.Request = None,
|
| 196 |
profile: Optional[gr.OAuthProfile] = None,
|
|
|
|
| 229 |
|
| 230 |
# Extract user info for telemetry (HF Spaces only)
|
| 231 |
# gr.LoginButton() OAuth provides user info via OAuthProfile
|
| 232 |
+
user_info = extract_user_info(request, IS_HF_SPACES, profile, session_id)
|
| 233 |
|
| 234 |
# Wait for core models download to complete (Paladin models can continue in background)
|
| 235 |
if _model_download_thread is not None and not _core_models_complete:
|
|
|
|
| 473 |
analysis_id=analysis_id, # For telemetry tracking
|
| 474 |
session_hash=session_hash, # For telemetry tracking
|
| 475 |
profile=profile, # For OAuth user info
|
| 476 |
+
session_id=session_id, # For OAuth session lookup
|
| 477 |
)
|
| 478 |
|
| 479 |
# Save results to cache if this is a TCGA slide
|
|
|
|
| 761 |
# stays permanently hidden.
|
| 762 |
login_button.load_event_to_attach = None
|
| 763 |
|
| 764 |
+
# Custom login display — sign-in via markdown link,
|
| 765 |
+
# sign-out via a Gradio button (stays in Gradio event system,
|
| 766 |
+
# avoiding navigation away from the SPA).
|
| 767 |
+
with gr.Row():
|
| 768 |
+
login_status_md = gr.Markdown(
|
| 769 |
+
value="[Sign in with HuggingFace](/api/auth/login) to save slides and results",
|
| 770 |
+
visible=True,
|
| 771 |
+
)
|
| 772 |
+
logout_button = gr.Button(
|
| 773 |
+
"Sign out", visible=False, size="sm", scale=0,
|
| 774 |
+
min_width=80,
|
| 775 |
+
)
|
| 776 |
|
| 777 |
gr.Markdown(
|
| 778 |
"# Mosaic: H&E Whole Slide Image Cancer Subtype and Biomarker Inference"
|
|
|
|
| 1223 |
gr.Group(visible=False),
|
| 1224 |
gr.Dataframe(visible=False),
|
| 1225 |
gr.File(visible=False),
|
| 1226 |
+
gr.Row(visible=True), # Show existing files row in upload mode
|
|
|
|
|
|
|
| 1227 |
)
|
| 1228 |
else:
|
| 1229 |
return (
|
|
|
|
| 1236 |
|
| 1237 |
# Load storage usage and show warnings
|
| 1238 |
def load_storage_usage(
|
| 1239 |
+
session_id: str = "",
|
| 1240 |
request: gr.Request = None,
|
| 1241 |
profile: Optional[gr.OAuthProfile] = None,
|
| 1242 |
):
|
| 1243 |
"""Load and display storage usage with warnings."""
|
| 1244 |
from mosaic.ui.user_tabs import _get_username
|
| 1245 |
|
| 1246 |
+
username, is_local = _get_username(request, profile, session_id)
|
| 1247 |
|
| 1248 |
if not username:
|
| 1249 |
# Not logged in on HF Spaces - hide storage info
|
|
|
|
| 1293 |
|
| 1294 |
# Load list of existing slides for re-analysis
|
| 1295 |
def load_existing_slides(
|
| 1296 |
+
session_id: str = "",
|
| 1297 |
request: gr.Request = None,
|
| 1298 |
profile: Optional[gr.OAuthProfile] = None,
|
| 1299 |
):
|
| 1300 |
"""Load dropdown with user's existing slides."""
|
| 1301 |
from mosaic.ui.user_tabs import _get_username
|
| 1302 |
|
| 1303 |
+
username, _ = _get_username(request, profile, session_id)
|
| 1304 |
|
| 1305 |
if not username:
|
| 1306 |
return gr.Dropdown(choices=[], value=None)
|
|
|
|
| 1338 |
def handle_slide_upload(
|
| 1339 |
files,
|
| 1340 |
slide_ids,
|
| 1341 |
+
session_id: str = "",
|
| 1342 |
request: gr.Request = None,
|
| 1343 |
profile: Optional[gr.OAuthProfile] = None,
|
| 1344 |
):
|
|
|
|
| 1349 |
# Determine username via OAuthProfile (primary) or request (fallback)
|
| 1350 |
from mosaic.ui.user_tabs import _get_username
|
| 1351 |
|
| 1352 |
+
username, _ = _get_username(request, profile, session_id)
|
| 1353 |
if not username:
|
| 1354 |
return files, slide_ids or {}
|
| 1355 |
|
|
|
|
| 1513 |
],
|
| 1514 |
)
|
| 1515 |
|
| 1516 |
+
# Hidden textbox that carries the localStorage session ID into
|
| 1517 |
+
# every Gradio event handler that needs the visitor's identity.
|
| 1518 |
+
# On HF Spaces the js= parameter on demo.load() fills this from
|
| 1519 |
+
# localStorage; locally it stays empty and _get_username() falls
|
| 1520 |
+
# back to LOCAL_DEBUG_USERNAME.
|
| 1521 |
+
session_id_input = gr.Textbox(visible=False, elem_id="session_id_input")
|
| 1522 |
+
# Hidden flag set to "1" only by the demo.load js= parameter.
|
| 1523 |
+
# On WebSocket reconnections JS doesn't re-run, so this stays
|
| 1524 |
+
# at its default "". The Python handler uses it to detect
|
| 1525 |
+
# reconnections and return gr.skip() to preserve UI state.
|
| 1526 |
+
_js_ran_flag = gr.Textbox(visible=False, value="")
|
| 1527 |
+
|
| 1528 |
# Handle file uploads - regenerate entire settings table
|
| 1529 |
# Save uploaded slides to user storage (HF Spaces only)
|
| 1530 |
input_slides.upload(
|
| 1531 |
handle_slide_upload,
|
| 1532 |
+
inputs=[input_slides, slide_ids_state, session_id_input],
|
| 1533 |
outputs=[input_slides, slide_ids_state],
|
| 1534 |
).then(
|
| 1535 |
# Refresh storage usage after upload
|
| 1536 |
load_storage_usage,
|
| 1537 |
+
inputs=[session_id_input],
|
| 1538 |
outputs=[storage_usage_warning, existing_file_row],
|
| 1539 |
).then(
|
| 1540 |
# Refresh existing slides list after upload
|
| 1541 |
load_existing_slides,
|
| 1542 |
+
inputs=[session_id_input],
|
| 1543 |
outputs=[existing_slides_dropdown],
|
| 1544 |
)
|
| 1545 |
|
| 1546 |
# Refresh existing slides dropdown
|
| 1547 |
refresh_existing_btn.click(
|
| 1548 |
load_existing_slides,
|
| 1549 |
+
inputs=[session_id_input],
|
| 1550 |
outputs=[existing_slides_dropdown],
|
| 1551 |
)
|
| 1552 |
|
| 1553 |
# Handle selection of existing slide(s) for re-analysis
|
| 1554 |
def select_existing_slide(
|
| 1555 |
slide_ids,
|
| 1556 |
+
session_id: str = "",
|
| 1557 |
request: gr.Request = None,
|
| 1558 |
profile: Optional[gr.OAuthProfile] = None,
|
| 1559 |
):
|
|
|
|
| 1561 |
|
| 1562 |
Args:
|
| 1563 |
slide_ids: Single slide_id (str) or list of slide_ids (multiselect)
|
| 1564 |
+
session_id: Server-side session ID from localStorage
|
| 1565 |
request: Gradio request object
|
| 1566 |
profile: OAuthProfile for HF Spaces login
|
| 1567 |
|
|
|
|
| 1577 |
|
| 1578 |
from mosaic.ui.user_tabs import _get_username
|
| 1579 |
|
| 1580 |
+
username, _ = _get_username(request, profile, session_id)
|
| 1581 |
if not username:
|
| 1582 |
return None
|
| 1583 |
|
|
|
|
| 1620 |
|
| 1621 |
existing_slides_dropdown.change(
|
| 1622 |
select_existing_slide,
|
| 1623 |
+
inputs=[existing_slides_dropdown, session_id_input],
|
| 1624 |
outputs=[input_slides],
|
| 1625 |
)
|
| 1626 |
|
|
|
|
| 1759 |
seg_config,
|
| 1760 |
user_dir,
|
| 1761 |
slide_ids,
|
| 1762 |
+
session_id,
|
| 1763 |
progress=gr.Progress(track_tqdm=True),
|
| 1764 |
request: gr.Request = None,
|
| 1765 |
profile: Optional[gr.OAuthProfile] = None,
|
|
|
|
| 1805 |
seg_config,
|
| 1806 |
user_dir,
|
| 1807 |
slide_ids=slide_ids,
|
| 1808 |
+
session_id=session_id,
|
| 1809 |
progress=progress,
|
| 1810 |
request=request,
|
| 1811 |
profile=profile,
|
|
|
|
| 1826 |
seg_config_dropdown,
|
| 1827 |
user_dir_state,
|
| 1828 |
slide_ids_state,
|
| 1829 |
+
session_id_input,
|
| 1830 |
],
|
| 1831 |
outputs=[
|
| 1832 |
settings_input,
|
|
|
|
| 1893 |
outputs=[user_dir_state],
|
| 1894 |
)
|
| 1895 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1896 |
# Add My Files and My Results tabs for user storage
|
| 1897 |
# On HF Spaces: visible for logged-in users
|
| 1898 |
# Locally: always visible with "local_user" for debugging
|
|
|
|
| 1917 |
with gr.Tab("My Files"):
|
| 1918 |
from mosaic.ui.user_tabs import create_my_files_tab
|
| 1919 |
|
| 1920 |
+
my_files_components = create_my_files_tab(session_id_input)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1921 |
|
| 1922 |
with gr.Tab("My Results"):
|
| 1923 |
from mosaic.ui.user_tabs import create_my_results_tab
|
| 1924 |
|
| 1925 |
+
my_results_components = create_my_results_tab(session_id_input)
|
| 1926 |
|
| 1927 |
+
# On HF Spaces: demo.load(js=...) reads the session ID from
|
| 1928 |
+
# localStorage AND sets a "JS ran" flag. Gradio re-fires
|
| 1929 |
+
# demo.load() on WebSocket reconnections WITHOUT re-executing
|
| 1930 |
+
# the JS — so the flag stays at its default "". The Python
|
| 1931 |
+
# handler detects this and returns gr.skip() to preserve the
|
| 1932 |
+
# UI state instead of overwriting the logged-in display.
|
| 1933 |
+
if IS_HF_SPACES:
|
| 1934 |
+
_load_files = my_files_components["load_files"]
|
| 1935 |
+
_load_results = my_results_components["load_results"]
|
| 1936 |
+
|
| 1937 |
+
_LS_JS = "() => [localStorage.getItem('mosaic_session_id') || '', '1']"
|
| 1938 |
+
|
| 1939 |
+
def on_page_load(session_id_from_browser, js_ran):
|
| 1940 |
+
"""Handle page load: check login, load files, load results.
|
| 1941 |
+
|
| 1942 |
+
On WebSocket reconnections, Gradio re-fires demo.load()
|
| 1943 |
+
but does NOT re-execute the js= parameter. The js_ran
|
| 1944 |
+
flag stays at its default "" on reconnections, allowing
|
| 1945 |
+
us to return gr.skip() and preserve the current UI state.
|
| 1946 |
+
"""
|
| 1947 |
+
from mosaic.ui.oauth import get_user_by_session_id, _sessions
|
| 1948 |
+
|
| 1949 |
+
# Reconnection detection: JS didn't run → skip all updates
|
| 1950 |
+
if js_ran != "1":
|
| 1951 |
+
logger.debug("on_page_load: reconnection detected, skipping")
|
| 1952 |
+
return gr.skip()
|
| 1953 |
+
|
| 1954 |
+
sid_repr = (
|
| 1955 |
+
session_id_from_browser[:8] + "..."
|
| 1956 |
+
if session_id_from_browser
|
| 1957 |
+
else "(empty)"
|
| 1958 |
+
)
|
| 1959 |
+
logger.info(
|
| 1960 |
+
f"on_page_load: sid={sid_repr}, "
|
| 1961 |
+
f"active_sessions={len(_sessions)}"
|
| 1962 |
)
|
| 1963 |
|
| 1964 |
+
if not session_id_from_browser:
|
| 1965 |
+
return _build_logged_out_result()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1966 |
|
| 1967 |
+
userinfo = get_user_by_session_id(session_id_from_browser)
|
| 1968 |
+
|
| 1969 |
+
if userinfo is not None:
|
| 1970 |
+
username = userinfo.get("preferred_username", "User")
|
| 1971 |
+
login_out = (
|
|
|
|
|
|
|
| 1972 |
gr.update(
|
| 1973 |
+
value=f"Signed in as **{username}**",
|
| 1974 |
visible=True,
|
| 1975 |
+
),
|
| 1976 |
+
gr.update(visible=True), # logout_button
|
| 1977 |
+
gr.update(visible=True), # user_storage_tabs
|
| 1978 |
+
gr.update(visible=True), # user_storage_header
|
| 1979 |
+
gr.update(visible=True), # user_storage_info
|
| 1980 |
)
|
| 1981 |
else:
|
| 1982 |
+
return _build_logged_out_result()
|
| 1983 |
+
|
| 1984 |
+
# Load storage usage + existing slides for the analysis page
|
| 1985 |
+
storage_out = load_storage_usage(session_id=session_id_from_browser)
|
| 1986 |
+
existing_out = load_existing_slides(session_id=session_id_from_browser)
|
| 1987 |
+
|
| 1988 |
+
# Load user files and results tabs
|
| 1989 |
+
try:
|
| 1990 |
+
files_out = _load_files(session_id=session_id_from_browser)
|
| 1991 |
+
except Exception as e:
|
| 1992 |
+
logger.error(f"on_page_load: _load_files error: {e}")
|
| 1993 |
+
files_out = (
|
| 1994 |
+
f"**Storage:** Error: {e}",
|
| 1995 |
+
[],
|
| 1996 |
+
gr.Textbox(visible=False),
|
| 1997 |
+
gr.File(visible=False),
|
| 1998 |
)
|
| 1999 |
+
try:
|
| 2000 |
+
results_out = _load_results(
|
| 2001 |
+
session_id=session_id_from_browser
|
| 2002 |
+
)
|
| 2003 |
+
except Exception as e:
|
| 2004 |
+
logger.error(f"on_page_load: _load_results error: {e}")
|
| 2005 |
+
results_out = (
|
| 2006 |
+
[],
|
| 2007 |
+
gr.Textbox(visible=False),
|
| 2008 |
+
gr.File(visible=False),
|
| 2009 |
+
)
|
| 2010 |
+
|
| 2011 |
+
# login(5) + session_id(1) + storage(2) + existing(1)
|
| 2012 |
+
# + files(4) + results(3) = 16
|
| 2013 |
+
return (
|
| 2014 |
+
*login_out,
|
| 2015 |
+
session_id_from_browser,
|
| 2016 |
+
*storage_out,
|
| 2017 |
+
existing_out,
|
| 2018 |
+
*files_out,
|
| 2019 |
+
*results_out,
|
| 2020 |
+
)
|
| 2021 |
+
|
| 2022 |
+
def _build_logged_out_result():
|
| 2023 |
+
"""Build the full logged-out output tuple (16 elements)."""
|
| 2024 |
+
return (
|
| 2025 |
+
gr.update(
|
| 2026 |
+
value="[Sign in with HuggingFace](/api/auth/login) to save slides and results",
|
| 2027 |
+
visible=True,
|
| 2028 |
+
),
|
| 2029 |
+
gr.update(visible=False), # logout_button
|
| 2030 |
+
gr.update(visible=False), # user_storage_tabs
|
| 2031 |
+
gr.update(visible=False), # user_storage_header
|
| 2032 |
+
gr.update(visible=False), # user_storage_info
|
| 2033 |
+
"", # session_id_input
|
| 2034 |
+
gr.Markdown(visible=False), # storage_usage_warning
|
| 2035 |
+
gr.Row(visible=False), # existing_file_row
|
| 2036 |
+
gr.Dropdown(choices=[], value=None), # existing_slides_dropdown
|
| 2037 |
+
"**Storage:** Not logged in",
|
| 2038 |
+
[], # files_table
|
| 2039 |
+
gr.Textbox(visible=False),
|
| 2040 |
+
gr.File(visible=False),
|
| 2041 |
+
[], # results_table
|
| 2042 |
+
gr.Textbox(visible=False),
|
| 2043 |
+
gr.File(visible=False),
|
| 2044 |
+
)
|
| 2045 |
|
| 2046 |
demo.load(
|
| 2047 |
+
on_page_load,
|
| 2048 |
+
inputs=[session_id_input, _js_ran_flag],
|
| 2049 |
outputs=[
|
| 2050 |
+
# login state (5)
|
| 2051 |
login_status_md,
|
| 2052 |
+
logout_button,
|
| 2053 |
user_storage_tabs,
|
| 2054 |
user_storage_header,
|
| 2055 |
user_storage_info,
|
| 2056 |
+
# persist session_id for subsequent button clicks (1)
|
| 2057 |
+
session_id_input,
|
| 2058 |
+
# analysis page: storage + existing slides (3)
|
| 2059 |
+
storage_usage_warning,
|
| 2060 |
+
existing_file_row,
|
| 2061 |
+
existing_slides_dropdown,
|
| 2062 |
+
# files tab (4)
|
| 2063 |
+
my_files_components["storage_usage"],
|
| 2064 |
+
my_files_components["files_table"],
|
| 2065 |
+
my_files_components["file_action_status"],
|
| 2066 |
+
my_files_components["slide_download_file"],
|
| 2067 |
+
# results tab (3)
|
| 2068 |
+
my_results_components["results_table"],
|
| 2069 |
+
my_results_components["result_action_status"],
|
| 2070 |
+
my_results_components["result_download_file"],
|
| 2071 |
],
|
| 2072 |
+
js=_LS_JS,
|
| 2073 |
+
)
|
| 2074 |
+
|
| 2075 |
+
# Sign-out: clear localStorage and reload.
|
| 2076 |
+
# On reload, demo.load fires; JS reads empty localStorage
|
| 2077 |
+
# and returns ['', '1']; on_page_load sees empty session_id
|
| 2078 |
+
# and returns the logged-out state.
|
| 2079 |
+
logout_button.click(
|
| 2080 |
+
fn=None,
|
| 2081 |
+
inputs=None,
|
| 2082 |
+
js="() => { localStorage.removeItem('mosaic_session_id'); location.reload(); }",
|
| 2083 |
)
|
| 2084 |
|
| 2085 |
# Use hardware-specific concurrency limit
|
|
|
|
| 2087 |
# Higher-memory GPUs and ZeroGPU can handle multiple concurrent analyses
|
| 2088 |
demo.queue(max_size=10, default_concurrency_limit=DEFAULT_CONCURRENCY_LIMIT)
|
| 2089 |
|
| 2090 |
+
# OAuth routes for HF Spaces (sdk:docker):
|
| 2091 |
+
# Custom OAuth routes (/api/auth/login, /api/auth/callback, /api/auth/logout)
|
| 2092 |
+
# are mounted via the lifespan protocol in create_oauth_lifespan(). This
|
| 2093 |
+
# ensures routes survive launch() recreating the app. Identity is read from
|
| 2094 |
+
# our server-side session (mosaic_auth cookie), not gr.OAuthProfile, which
|
| 2095 |
+
# returns the Space owner's identity on Docker SDK Spaces.
|
| 2096 |
+
|
| 2097 |
# Register cleanup handler for graceful shutdown
|
| 2098 |
import atexit
|
| 2099 |
|
|
|
|
| 2125 |
heartbeat_thread = threading.Thread(target=heartbeat_worker, daemon=True)
|
| 2126 |
heartbeat_thread.start()
|
| 2127 |
|
| 2128 |
+
# On HF Spaces, mount custom OAuth routes via the lifespan protocol.
|
| 2129 |
+
# Gradio's launch() recreates the app, but the lifespan handler receives
|
| 2130 |
+
# the final App instance, so routes added there survive.
|
| 2131 |
+
launch_kwargs = {}
|
| 2132 |
+
if IS_HF_SPACES:
|
| 2133 |
+
from mosaic.ui.oauth import create_oauth_lifespan
|
| 2134 |
+
|
| 2135 |
+
launch_kwargs["app_kwargs"] = {"lifespan": create_oauth_lifespan}
|
| 2136 |
+
|
| 2137 |
demo.launch(
|
| 2138 |
server_name=server_name,
|
| 2139 |
share=share,
|
| 2140 |
server_port=server_port,
|
| 2141 |
show_error=True,
|
| 2142 |
favicon_path=current_dir / "favicon.svg",
|
| 2143 |
+
**launch_kwargs,
|
| 2144 |
)
|
src/mosaic/ui/oauth.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Manual OAuth flow for HF Spaces with Docker SDK.
|
| 2 |
+
|
| 3 |
+
On HF Spaces with sdk:docker, Gradio's built-in OAuth (gr.LoginButton /
|
| 4 |
+
gr.OAuthProfile) doesn't work because the HF reverse proxy injects the
|
| 5 |
+
Space owner's identity into every session. This module implements the
|
| 6 |
+
Authorization Code flow directly against HF's OAuth provider.
|
| 7 |
+
|
| 8 |
+
The HF proxy strips all custom cookies — only its own forged ``session``
|
| 9 |
+
cookie survives. To persist the visitor's session ID across page loads,
|
| 10 |
+
the callback stores it in the browser's ``localStorage`` via an
|
| 11 |
+
intermediate HTML page. The Gradio ``demo.load()`` handler reads it back
|
| 12 |
+
via a ``js=`` parameter and passes it to the Python handler, which looks
|
| 13 |
+
up the server-side session.
|
| 14 |
+
|
| 15 |
+
Environment variables (set automatically by HF Spaces):
|
| 16 |
+
OAUTH_CLIENT_ID: OAuth application client ID
|
| 17 |
+
OAUTH_CLIENT_SECRET: OAuth application client secret
|
| 18 |
+
OAUTH_SCOPES: Space-separated scopes (default: "openid profile")
|
| 19 |
+
SPACE_HOST: Public hostname of the Space (e.g. "user-space.hf.space")
|
| 20 |
+
|
| 21 |
+
Routes mounted on the Gradio ASGI app:
|
| 22 |
+
GET /api/auth/login -> Redirect to HF authorize endpoint
|
| 23 |
+
GET /api/auth/callback -> Exchange code for token, store session via localStorage
|
| 24 |
+
GET /api/auth/logout -> Clear session, clear localStorage, redirect to /
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
import contextlib
|
| 28 |
+
import html as html_module
|
| 29 |
+
import json
|
| 30 |
+
import os
|
| 31 |
+
import secrets
|
| 32 |
+
import time
|
| 33 |
+
from typing import Optional
|
| 34 |
+
from urllib.parse import urlencode
|
| 35 |
+
|
| 36 |
+
from loguru import logger
|
| 37 |
+
from starlette.requests import Request
|
| 38 |
+
from starlette.responses import RedirectResponse, JSONResponse, HTMLResponse
|
| 39 |
+
from starlette.routing import Route
|
| 40 |
+
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
# Configuration
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
|
| 45 |
+
OAUTH_CLIENT_ID = os.environ.get("OAUTH_CLIENT_ID", "")
|
| 46 |
+
OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
|
| 47 |
+
OAUTH_SCOPES = os.environ.get("OAUTH_SCOPES", "openid profile")
|
| 48 |
+
SPACE_HOST = os.environ.get("SPACE_HOST", "")
|
| 49 |
+
|
| 50 |
+
HF_AUTHORIZE_URL = "https://huggingface.co/oauth/authorize"
|
| 51 |
+
HF_TOKEN_URL = "https://huggingface.co/oauth/token"
|
| 52 |
+
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
# Server-side session store
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
|
| 57 |
+
# {session_id: {"userinfo": {...}, "created_at": float}}
|
| 58 |
+
_sessions: dict[str, dict] = {}
|
| 59 |
+
_SESSION_TTL_SEC = 24 * 60 * 60 # 24 hours
|
| 60 |
+
_SESSION_COOKIE = "mosaic_auth" # kept for backward-compat with tests
|
| 61 |
+
|
| 62 |
+
# localStorage key used by the browser to persist the session ID
|
| 63 |
+
_LOCALSTORAGE_KEY = "mosaic_session_id"
|
| 64 |
+
|
| 65 |
+
# In-memory CSRF state tokens: {state_value: created_at_float}
|
| 66 |
+
_pending_states: dict[str, float] = {}
|
| 67 |
+
_STATE_TTL_SEC = 10 * 60 # 10 minutes
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _prune_expired() -> None:
|
| 71 |
+
"""Remove expired sessions and CSRF state tokens."""
|
| 72 |
+
now = time.time()
|
| 73 |
+
expired_sessions = [
|
| 74 |
+
k for k, v in _sessions.items() if now - v["created_at"] > _SESSION_TTL_SEC
|
| 75 |
+
]
|
| 76 |
+
for k in expired_sessions:
|
| 77 |
+
del _sessions[k]
|
| 78 |
+
expired_states = [k for k, v in _pending_states.items() if now - v > _STATE_TTL_SEC]
|
| 79 |
+
for k in expired_states:
|
| 80 |
+
del _pending_states[k]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def get_user_from_server_session(request) -> Optional[dict]:
|
| 84 |
+
"""Look up user info from the server-side session store.
|
| 85 |
+
|
| 86 |
+
Checks the ``mosaic_auth`` cookie in the request and returns the
|
| 87 |
+
stored userinfo dict, or None if the cookie is missing/expired.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
request: Starlette/Gradio request object (needs .cookies or .headers)
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
userinfo dict with at least ``preferred_username`` key, or None
|
| 94 |
+
"""
|
| 95 |
+
_prune_expired()
|
| 96 |
+
|
| 97 |
+
cookie_val = None
|
| 98 |
+
# Gradio's gr.Request wraps cookies/headers through dict_to_obj() which
|
| 99 |
+
# returns a gradio.route_utils.Obj, NOT a plain dict. Use hasattr/get
|
| 100 |
+
# instead of isinstance(dict) to support both dict and Obj.
|
| 101 |
+
if hasattr(request, "cookies"):
|
| 102 |
+
cookies = request.cookies
|
| 103 |
+
if hasattr(cookies, "get"):
|
| 104 |
+
cookie_val = cookies.get(_SESSION_COOKIE)
|
| 105 |
+
logger.debug(
|
| 106 |
+
f"Session lookup: cookies type={type(cookies).__name__}, "
|
| 107 |
+
f"cookie_val={cookie_val!r}"
|
| 108 |
+
)
|
| 109 |
+
# Fallback: parse Cookie header
|
| 110 |
+
if cookie_val is None and hasattr(request, "headers"):
|
| 111 |
+
headers = request.headers
|
| 112 |
+
cookie_header = ""
|
| 113 |
+
if hasattr(headers, "get"):
|
| 114 |
+
cookie_header = headers.get("cookie", "") or ""
|
| 115 |
+
if cookie_header:
|
| 116 |
+
for part in cookie_header.split(";"):
|
| 117 |
+
part = part.strip()
|
| 118 |
+
if part.startswith(f"{_SESSION_COOKIE}="):
|
| 119 |
+
cookie_val = part[len(f"{_SESSION_COOKIE}=") :]
|
| 120 |
+
break
|
| 121 |
+
logger.debug(
|
| 122 |
+
f"Session lookup header fallback: has_cookie_header={bool(cookie_header)}, "
|
| 123 |
+
f"cookie_val={cookie_val!r}"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
if not cookie_val:
|
| 127 |
+
logger.debug(
|
| 128 |
+
f"No {_SESSION_COOKIE} cookie found. "
|
| 129 |
+
f"Active sessions: {len(_sessions)}"
|
| 130 |
+
)
|
| 131 |
+
return None
|
| 132 |
+
|
| 133 |
+
entry = _sessions.get(cookie_val)
|
| 134 |
+
if entry is None:
|
| 135 |
+
logger.debug(
|
| 136 |
+
f"{_SESSION_COOKIE} cookie present ({cookie_val[:8]}...) "
|
| 137 |
+
f"but no matching server session. "
|
| 138 |
+
f"Active sessions: {list(_sessions.keys())[:3]}"
|
| 139 |
+
)
|
| 140 |
+
return None
|
| 141 |
+
|
| 142 |
+
if time.time() - entry["created_at"] > _SESSION_TTL_SEC:
|
| 143 |
+
del _sessions[cookie_val]
|
| 144 |
+
return None
|
| 145 |
+
|
| 146 |
+
return entry.get("userinfo")
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def get_user_by_session_id(session_id: Optional[str]) -> Optional[dict]:
|
| 150 |
+
"""Look up user info by a raw session ID (from localStorage).
|
| 151 |
+
|
| 152 |
+
This is the primary lookup method on HF Spaces where the HF proxy
|
| 153 |
+
strips custom cookies. The session ID is stored in the browser's
|
| 154 |
+
localStorage by the callback page and passed to Gradio handlers via
|
| 155 |
+
the ``js=`` parameter on ``demo.load()``.
|
| 156 |
+
|
| 157 |
+
Args:
|
| 158 |
+
session_id: The session ID string (from localStorage), or empty/None.
|
| 159 |
+
|
| 160 |
+
Returns:
|
| 161 |
+
userinfo dict with at least ``preferred_username`` key, or None.
|
| 162 |
+
"""
|
| 163 |
+
_prune_expired()
|
| 164 |
+
|
| 165 |
+
if not session_id:
|
| 166 |
+
return None
|
| 167 |
+
|
| 168 |
+
entry = _sessions.get(session_id)
|
| 169 |
+
if entry is None:
|
| 170 |
+
logger.debug(
|
| 171 |
+
f"Session ID ({session_id[:8]}...) not found. "
|
| 172 |
+
f"Active sessions: {len(_sessions)}"
|
| 173 |
+
)
|
| 174 |
+
return None
|
| 175 |
+
|
| 176 |
+
if time.time() - entry["created_at"] > _SESSION_TTL_SEC:
|
| 177 |
+
del _sessions[session_id]
|
| 178 |
+
return None
|
| 179 |
+
|
| 180 |
+
return entry.get("userinfo")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# ---------------------------------------------------------------------------
|
| 184 |
+
# Route handlers
|
| 185 |
+
# ---------------------------------------------------------------------------
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
async def _login(request: Request):
|
| 189 |
+
"""Redirect to HF OAuth authorize endpoint."""
|
| 190 |
+
if not OAUTH_CLIENT_ID or not SPACE_HOST:
|
| 191 |
+
return JSONResponse(
|
| 192 |
+
{"error": "OAuth not configured (missing OAUTH_CLIENT_ID or SPACE_HOST)"},
|
| 193 |
+
status_code=500,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
_prune_expired()
|
| 197 |
+
state = secrets.token_urlsafe(32)
|
| 198 |
+
_pending_states[state] = time.time()
|
| 199 |
+
|
| 200 |
+
# Build redirect URI pointing back to our callback
|
| 201 |
+
redirect_uri = f"https://{SPACE_HOST}/api/auth/callback"
|
| 202 |
+
|
| 203 |
+
params = {
|
| 204 |
+
"client_id": OAUTH_CLIENT_ID,
|
| 205 |
+
"redirect_uri": redirect_uri,
|
| 206 |
+
"response_type": "code",
|
| 207 |
+
"scope": OAUTH_SCOPES,
|
| 208 |
+
"state": state,
|
| 209 |
+
}
|
| 210 |
+
authorize_url = f"{HF_AUTHORIZE_URL}?{urlencode(params)}"
|
| 211 |
+
logger.info(f"OAuth login: redirecting to HF authorize endpoint")
|
| 212 |
+
return RedirectResponse(authorize_url, status_code=302)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
async def _callback(request: Request):
|
| 216 |
+
"""Handle OAuth callback: exchange code for token, set session."""
|
| 217 |
+
import httpx
|
| 218 |
+
|
| 219 |
+
code = request.query_params.get("code")
|
| 220 |
+
state = request.query_params.get("state")
|
| 221 |
+
|
| 222 |
+
if not code or not state:
|
| 223 |
+
return JSONResponse(
|
| 224 |
+
{"error": "Missing code or state parameter"}, status_code=400
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
# Validate CSRF state
|
| 228 |
+
_prune_expired()
|
| 229 |
+
created_at = _pending_states.pop(state, None)
|
| 230 |
+
if created_at is None:
|
| 231 |
+
return JSONResponse(
|
| 232 |
+
{"error": "Invalid or expired state parameter"}, status_code=400
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
# Exchange authorization code for access token
|
| 236 |
+
redirect_uri = f"https://{SPACE_HOST}/api/auth/callback"
|
| 237 |
+
token_data = {
|
| 238 |
+
"grant_type": "authorization_code",
|
| 239 |
+
"code": code,
|
| 240 |
+
"redirect_uri": redirect_uri,
|
| 241 |
+
"client_id": OAUTH_CLIENT_ID,
|
| 242 |
+
"client_secret": OAUTH_CLIENT_SECRET,
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
try:
|
| 246 |
+
async with httpx.AsyncClient() as client:
|
| 247 |
+
resp = await client.post(
|
| 248 |
+
HF_TOKEN_URL,
|
| 249 |
+
data=token_data,
|
| 250 |
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
| 251 |
+
)
|
| 252 |
+
resp.raise_for_status()
|
| 253 |
+
token_response = resp.json()
|
| 254 |
+
except httpx.HTTPStatusError as e:
|
| 255 |
+
logger.error(
|
| 256 |
+
f"OAuth token exchange failed: {e.response.status_code} {e.response.text}"
|
| 257 |
+
)
|
| 258 |
+
return JSONResponse({"error": "Token exchange failed"}, status_code=502)
|
| 259 |
+
except Exception as e:
|
| 260 |
+
logger.error(f"OAuth token exchange error: {e}")
|
| 261 |
+
return JSONResponse({"error": "Token exchange failed"}, status_code=502)
|
| 262 |
+
|
| 263 |
+
# Extract userinfo from the id_token (JWT) or use the userinfo endpoint
|
| 264 |
+
access_token = token_response.get("access_token")
|
| 265 |
+
userinfo = None
|
| 266 |
+
|
| 267 |
+
# Try to decode the id_token (JWT) for userinfo
|
| 268 |
+
id_token = token_response.get("id_token")
|
| 269 |
+
if id_token:
|
| 270 |
+
try:
|
| 271 |
+
# JWT is base64url-encoded: header.payload.signature
|
| 272 |
+
# We only need the payload (claims) — no signature verification
|
| 273 |
+
# since we just received this directly from HF's token endpoint
|
| 274 |
+
import base64
|
| 275 |
+
|
| 276 |
+
payload_b64 = id_token.split(".")[1]
|
| 277 |
+
# Add padding if needed
|
| 278 |
+
padding = 4 - len(payload_b64) % 4
|
| 279 |
+
if padding != 4:
|
| 280 |
+
payload_b64 += "=" * padding
|
| 281 |
+
payload_bytes = base64.urlsafe_b64decode(payload_b64)
|
| 282 |
+
userinfo = json.loads(payload_bytes)
|
| 283 |
+
except Exception as e:
|
| 284 |
+
logger.warning(f"Failed to decode id_token: {e}")
|
| 285 |
+
|
| 286 |
+
# Fallback: call userinfo endpoint
|
| 287 |
+
if userinfo is None and access_token:
|
| 288 |
+
try:
|
| 289 |
+
async with httpx.AsyncClient() as client:
|
| 290 |
+
resp = await client.get(
|
| 291 |
+
"https://huggingface.co/oauth/userinfo",
|
| 292 |
+
headers={"Authorization": f"Bearer {access_token}"},
|
| 293 |
+
)
|
| 294 |
+
resp.raise_for_status()
|
| 295 |
+
userinfo = resp.json()
|
| 296 |
+
except Exception as e:
|
| 297 |
+
logger.error(f"Failed to fetch userinfo: {e}")
|
| 298 |
+
return JSONResponse({"error": "Failed to get user info"}, status_code=502)
|
| 299 |
+
|
| 300 |
+
if not userinfo:
|
| 301 |
+
return JSONResponse({"error": "No user info received"}, status_code=502)
|
| 302 |
+
|
| 303 |
+
# Extract username
|
| 304 |
+
username = userinfo.get("preferred_username") or userinfo.get("sub", "unknown")
|
| 305 |
+
logger.info(f"OAuth callback: authenticated user '{username}'")
|
| 306 |
+
|
| 307 |
+
# Store in server-side session dict (keyed by random session ID)
|
| 308 |
+
session_id = secrets.token_urlsafe(32)
|
| 309 |
+
_sessions[session_id] = {
|
| 310 |
+
"userinfo": userinfo,
|
| 311 |
+
"created_at": time.time(),
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
# The HF proxy strips custom cookies, so we can't use Set-Cookie to
|
| 315 |
+
# persist the session ID. Instead, return a small HTML page that
|
| 316 |
+
# stores the session ID in localStorage and then redirects to /.
|
| 317 |
+
safe_sid = html_module.escape(session_id)
|
| 318 |
+
page = f"""<!DOCTYPE html>
|
| 319 |
+
<html><head><title>Signing in...</title></head>
|
| 320 |
+
<body><p>Signing in…</p>
|
| 321 |
+
<script>
|
| 322 |
+
try {{ localStorage.setItem('{_LOCALSTORAGE_KEY}', '{safe_sid}'); }}
|
| 323 |
+
catch(e) {{ console.warn('Could not store session:', e); }}
|
| 324 |
+
window.location.replace('/');
|
| 325 |
+
</script>
|
| 326 |
+
<noscript><a href="/">Click here to continue</a></noscript>
|
| 327 |
+
</body></html>"""
|
| 328 |
+
return HTMLResponse(page)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
async def _logout(request: Request):
|
| 332 |
+
"""Clear server-side session and browser localStorage, then redirect."""
|
| 333 |
+
# Try to clear server-side session from query param (set by JS)
|
| 334 |
+
sid = request.query_params.get("sid", "")
|
| 335 |
+
if sid and sid in _sessions:
|
| 336 |
+
del _sessions[sid]
|
| 337 |
+
logger.info(f"Cleared server-side session for logout ({sid[:8]}...)")
|
| 338 |
+
|
| 339 |
+
# Return HTML that clears localStorage and redirects
|
| 340 |
+
page = f"""<!DOCTYPE html>
|
| 341 |
+
<html><head><title>Signing out...</title></head>
|
| 342 |
+
<body><p>Signing out…</p>
|
| 343 |
+
<script>
|
| 344 |
+
try {{ localStorage.removeItem('{_LOCALSTORAGE_KEY}'); }}
|
| 345 |
+
catch(e) {{ console.warn('Could not clear session:', e); }}
|
| 346 |
+
window.location.replace('/');
|
| 347 |
+
</script>
|
| 348 |
+
<noscript><a href="/">Click here to continue</a></noscript>
|
| 349 |
+
</body></html>"""
|
| 350 |
+
return HTMLResponse(page)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
# ---------------------------------------------------------------------------
|
| 354 |
+
# Mount helper
|
| 355 |
+
# ---------------------------------------------------------------------------
|
| 356 |
+
|
| 357 |
+
_oauth_routes = [
|
| 358 |
+
Route("/api/auth/login", _login, methods=["GET"]),
|
| 359 |
+
Route("/api/auth/callback", _callback, methods=["GET"]),
|
| 360 |
+
Route("/api/auth/logout", _logout, methods=["GET"]),
|
| 361 |
+
]
|
| 362 |
+
|
| 363 |
+
def mount_oauth_routes(app) -> None:
|
| 364 |
+
"""Mount custom OAuth routes on the Gradio ASGI app.
|
| 365 |
+
|
| 366 |
+
.. deprecated::
|
| 367 |
+
Gradio's ``launch()`` recreates ``demo.app`` from scratch, discarding
|
| 368 |
+
any routes prepended beforehand. Use :func:`create_oauth_lifespan`
|
| 369 |
+
instead, which mounts routes on the **final** app instance via the
|
| 370 |
+
lifespan protocol.
|
| 371 |
+
|
| 372 |
+
Args:
|
| 373 |
+
app: The Starlette/FastAPI app (``demo.app``)
|
| 374 |
+
"""
|
| 375 |
+
if not OAUTH_CLIENT_ID:
|
| 376 |
+
logger.warning(
|
| 377 |
+
"OAuth routes not mounted: OAUTH_CLIENT_ID not set. "
|
| 378 |
+
"Custom login will not work."
|
| 379 |
+
)
|
| 380 |
+
return
|
| 381 |
+
|
| 382 |
+
# Insert our routes at the beginning so they take priority
|
| 383 |
+
app.routes[:0] = _oauth_routes
|
| 384 |
+
logger.info(
|
| 385 |
+
f"Mounted custom OAuth routes: /api/auth/login, /api/auth/callback, /api/auth/logout"
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
@contextlib.asynccontextmanager
|
| 390 |
+
async def create_oauth_lifespan(app):
|
| 391 |
+
"""ASGI lifespan handler that mounts custom OAuth routes.
|
| 392 |
+
|
| 393 |
+
Gradio's ``launch()`` recreates ``demo.app`` via ``App.create_app()``,
|
| 394 |
+
discarding any routes prepended before launch. However,
|
| 395 |
+
``create_lifespan_handler()`` (in Gradio's ``route_utils.py``) wraps
|
| 396 |
+
user-supplied lifespan and calls it with the **final** App instance.
|
| 397 |
+
Routes added here are on the real app with the full middleware stack
|
| 398 |
+
(including ``SessionMiddleware``).
|
| 399 |
+
|
| 400 |
+
Pass this to ``demo.launch(app_kwargs={"lifespan": create_oauth_lifespan})``.
|
| 401 |
+
"""
|
| 402 |
+
if OAUTH_CLIENT_ID:
|
| 403 |
+
app.routes[:0] = _oauth_routes
|
| 404 |
+
logger.info(
|
| 405 |
+
"Custom OAuth routes mounted via lifespan: "
|
| 406 |
+
"/api/auth/login, /api/auth/callback, /api/auth/logout"
|
| 407 |
+
)
|
| 408 |
+
yield
|
src/mosaic/ui/user_tabs.py
CHANGED
|
@@ -29,40 +29,57 @@ from mosaic.user_results import (
|
|
| 29 |
LOCAL_DEBUG_USERNAME = "local_user"
|
| 30 |
|
| 31 |
|
| 32 |
-
def _get_username(
|
|
|
|
|
|
|
| 33 |
"""Get username for storage operations.
|
| 34 |
|
| 35 |
-
On HF Spaces
|
| 36 |
-
the
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
Returns:
|
| 40 |
Tuple of (username, is_local_mode)
|
| 41 |
-
- In HF Spaces: (
|
| 42 |
- Locally: (LOCAL_DEBUG_USERNAME, True)
|
| 43 |
- HF Spaces not logged in: (None, False)
|
| 44 |
"""
|
| 45 |
if IS_HF_SPACES:
|
| 46 |
-
#
|
| 47 |
-
|
| 48 |
-
# so we must NOT fall back to it.
|
| 49 |
-
if profile is not None:
|
| 50 |
try:
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
except Exception:
|
| 55 |
pass
|
| 56 |
|
|
|
|
|
|
|
| 57 |
return (None, False)
|
| 58 |
else:
|
| 59 |
# Local mode - use debug username
|
| 60 |
return (LOCAL_DEBUG_USERNAME, True)
|
| 61 |
|
| 62 |
|
| 63 |
-
def create_my_files_tab():
|
| 64 |
"""Create the My Files tab UI for managing uploaded slides.
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
Returns:
|
| 67 |
Dictionary of UI components for event handlers
|
| 68 |
"""
|
|
@@ -107,9 +124,13 @@ def create_my_files_tab():
|
|
| 107 |
# Download output
|
| 108 |
slide_download_file = gr.File(label="Download", visible=False)
|
| 109 |
|
| 110 |
-
def load_files(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
"""Load user's uploaded files."""
|
| 112 |
-
username, is_local = _get_username(request, profile)
|
| 113 |
|
| 114 |
if not username:
|
| 115 |
return (
|
|
@@ -188,11 +209,12 @@ def create_my_files_tab():
|
|
| 188 |
|
| 189 |
def download_slide(
|
| 190 |
slide_id_or_name,
|
|
|
|
| 191 |
request: gr.Request = None,
|
| 192 |
profile: gr.OAuthProfile = None,
|
| 193 |
):
|
| 194 |
"""Download the original slide file."""
|
| 195 |
-
username, _ = _get_username(request, profile)
|
| 196 |
|
| 197 |
if not username or not slide_id_or_name:
|
| 198 |
return gr.File(visible=False), gr.Textbox(
|
|
@@ -226,11 +248,12 @@ def create_my_files_tab():
|
|
| 226 |
|
| 227 |
def delete_slide(
|
| 228 |
slide_id_or_name,
|
|
|
|
| 229 |
request: gr.Request = None,
|
| 230 |
profile: gr.OAuthProfile = None,
|
| 231 |
):
|
| 232 |
"""Delete a slide and all associated results."""
|
| 233 |
-
username, _ = _get_username(request, profile)
|
| 234 |
|
| 235 |
if not username or not slide_id_or_name:
|
| 236 |
return gr.Textbox(
|
|
@@ -257,10 +280,14 @@ def create_my_files_tab():
|
|
| 257 |
logger.error(f"Failed to delete slide {slide_id_or_name}: {e}")
|
| 258 |
return gr.Textbox(value=f"Error: {str(e)}", visible=True)
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
# Wire up events
|
| 261 |
refresh_files_btn.click(
|
| 262 |
load_files,
|
| 263 |
-
inputs=None,
|
| 264 |
outputs=[
|
| 265 |
storage_usage_md,
|
| 266 |
files_table,
|
|
@@ -271,17 +298,17 @@ def create_my_files_tab():
|
|
| 271 |
|
| 272 |
download_slide_btn.click(
|
| 273 |
download_slide,
|
| 274 |
-
inputs=[selected_slide_id],
|
| 275 |
outputs=[slide_download_file, file_action_status],
|
| 276 |
)
|
| 277 |
|
| 278 |
delete_slide_btn.click(
|
| 279 |
delete_slide,
|
| 280 |
-
inputs=[selected_slide_id],
|
| 281 |
outputs=[file_action_status],
|
| 282 |
).then(
|
| 283 |
load_files, # Refresh after delete
|
| 284 |
-
inputs=None,
|
| 285 |
outputs=[
|
| 286 |
storage_usage_md,
|
| 287 |
files_table,
|
|
@@ -313,9 +340,13 @@ def create_my_files_tab():
|
|
| 313 |
}
|
| 314 |
|
| 315 |
|
| 316 |
-
def create_my_results_tab():
|
| 317 |
"""Create the My Results tab UI for browsing analysis results.
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
Returns:
|
| 320 |
Dictionary of UI components for event handlers
|
| 321 |
"""
|
|
@@ -364,9 +395,13 @@ def create_my_results_tab():
|
|
| 364 |
)
|
| 365 |
result_download_file = gr.File(label="Download", visible=False)
|
| 366 |
|
| 367 |
-
def load_results(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
"""Load user's analysis results."""
|
| 369 |
-
username, _ = _get_username(request, profile)
|
| 370 |
|
| 371 |
if not username:
|
| 372 |
return [], gr.Textbox(visible=False), gr.File(visible=False)
|
|
@@ -403,10 +438,13 @@ def create_my_results_tab():
|
|
| 403 |
)
|
| 404 |
|
| 405 |
def view_result_details(
|
| 406 |
-
analysis_id,
|
|
|
|
|
|
|
|
|
|
| 407 |
):
|
| 408 |
"""Load and display result details."""
|
| 409 |
-
username, _ = _get_username(request, profile)
|
| 410 |
|
| 411 |
if not username or not analysis_id:
|
| 412 |
return (
|
|
@@ -515,10 +553,13 @@ def create_my_results_tab():
|
|
| 515 |
)
|
| 516 |
|
| 517 |
def download_result_zip(
|
| 518 |
-
analysis_id,
|
|
|
|
|
|
|
|
|
|
| 519 |
):
|
| 520 |
"""Create and download ZIP of result files."""
|
| 521 |
-
username, _ = _get_username(request, profile)
|
| 522 |
|
| 523 |
if not username or not analysis_id:
|
| 524 |
return gr.File(visible=False), gr.Textbox(
|
|
@@ -544,10 +585,13 @@ def create_my_results_tab():
|
|
| 544 |
)
|
| 545 |
|
| 546 |
def delete_result(
|
| 547 |
-
analysis_id,
|
|
|
|
|
|
|
|
|
|
| 548 |
):
|
| 549 |
"""Delete an analysis result."""
|
| 550 |
-
username, _ = _get_username(request, profile)
|
| 551 |
|
| 552 |
if not username or not analysis_id:
|
| 553 |
return gr.Textbox(
|
|
@@ -568,16 +612,19 @@ def create_my_results_tab():
|
|
| 568 |
logger.error(f"Failed to delete result {analysis_id}: {e}")
|
| 569 |
return gr.Textbox(value=f"Error: {str(e)}", visible=True)
|
| 570 |
|
|
|
|
|
|
|
|
|
|
| 571 |
# Wire up events
|
| 572 |
refresh_results_btn.click(
|
| 573 |
load_results,
|
| 574 |
-
inputs=None,
|
| 575 |
outputs=[results_table, result_action_status, result_download_file],
|
| 576 |
)
|
| 577 |
|
| 578 |
view_result_btn.click(
|
| 579 |
view_result_details,
|
| 580 |
-
inputs=[selected_analysis_id],
|
| 581 |
outputs=[
|
| 582 |
result_details,
|
| 583 |
result_metadata_md,
|
|
@@ -590,17 +637,17 @@ def create_my_results_tab():
|
|
| 590 |
|
| 591 |
download_zip_btn.click(
|
| 592 |
download_result_zip,
|
| 593 |
-
inputs=[selected_analysis_id],
|
| 594 |
outputs=[result_download_file, result_action_status],
|
| 595 |
)
|
| 596 |
|
| 597 |
delete_result_btn.click(
|
| 598 |
delete_result,
|
| 599 |
-
inputs=[selected_analysis_id],
|
| 600 |
outputs=[result_action_status],
|
| 601 |
).then(
|
| 602 |
load_results, # Refresh after delete
|
| 603 |
-
inputs=None,
|
| 604 |
outputs=[results_table, result_action_status, result_download_file],
|
| 605 |
)
|
| 606 |
|
|
|
|
| 29 |
LOCAL_DEBUG_USERNAME = "local_user"
|
| 30 |
|
| 31 |
|
| 32 |
+
def _get_username(
|
| 33 |
+
request: gr.Request = None, profile=None, session_id: str = ""
|
| 34 |
+
) -> tuple[str, bool]:
|
| 35 |
"""Get username for storage operations.
|
| 36 |
|
| 37 |
+
On HF Spaces with Docker SDK the HF proxy strips custom cookies and
|
| 38 |
+
forges the Starlette session so ``gr.OAuthProfile`` always returns the
|
| 39 |
+
Space owner's identity. The only trustworthy channel is the session ID
|
| 40 |
+
stored in the browser's ``localStorage`` and passed via Gradio inputs.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
request: Gradio request (auto-injected).
|
| 44 |
+
profile: OAuthProfile (auto-injected, **unreliable on HF Spaces**).
|
| 45 |
+
session_id: Server-side session ID from localStorage (reliable).
|
| 46 |
|
| 47 |
Returns:
|
| 48 |
Tuple of (username, is_local_mode)
|
| 49 |
+
- In HF Spaces: (username, False) if session_id is valid
|
| 50 |
- Locally: (LOCAL_DEBUG_USERNAME, True)
|
| 51 |
- HF Spaces not logged in: (None, False)
|
| 52 |
"""
|
| 53 |
if IS_HF_SPACES:
|
| 54 |
+
# Look up user by session ID passed from localStorage via Gradio js=
|
| 55 |
+
if session_id:
|
|
|
|
|
|
|
| 56 |
try:
|
| 57 |
+
from mosaic.ui.oauth import get_user_by_session_id
|
| 58 |
+
|
| 59 |
+
userinfo = get_user_by_session_id(session_id)
|
| 60 |
+
if userinfo:
|
| 61 |
+
username = userinfo.get("preferred_username")
|
| 62 |
+
if username:
|
| 63 |
+
return (username, False)
|
| 64 |
except Exception:
|
| 65 |
pass
|
| 66 |
|
| 67 |
+
# Do NOT fall back to OAuthProfile — it always returns the Space
|
| 68 |
+
# owner's identity on Docker SDK Spaces.
|
| 69 |
return (None, False)
|
| 70 |
else:
|
| 71 |
# Local mode - use debug username
|
| 72 |
return (LOCAL_DEBUG_USERNAME, True)
|
| 73 |
|
| 74 |
|
| 75 |
+
def create_my_files_tab(session_id_component=None):
|
| 76 |
"""Create the My Files tab UI for managing uploaded slides.
|
| 77 |
|
| 78 |
+
Args:
|
| 79 |
+
session_id_component: Hidden gr.Textbox carrying the localStorage
|
| 80 |
+
session ID. Passed as input to event handlers so that
|
| 81 |
+
``_get_username`` can resolve the visitor's identity on HF Spaces.
|
| 82 |
+
|
| 83 |
Returns:
|
| 84 |
Dictionary of UI components for event handlers
|
| 85 |
"""
|
|
|
|
| 124 |
# Download output
|
| 125 |
slide_download_file = gr.File(label="Download", visible=False)
|
| 126 |
|
| 127 |
+
def load_files(
|
| 128 |
+
session_id: str = "",
|
| 129 |
+
request: gr.Request = None,
|
| 130 |
+
profile: gr.OAuthProfile = None,
|
| 131 |
+
):
|
| 132 |
"""Load user's uploaded files."""
|
| 133 |
+
username, is_local = _get_username(request, profile, session_id)
|
| 134 |
|
| 135 |
if not username:
|
| 136 |
return (
|
|
|
|
| 209 |
|
| 210 |
def download_slide(
|
| 211 |
slide_id_or_name,
|
| 212 |
+
session_id: str = "",
|
| 213 |
request: gr.Request = None,
|
| 214 |
profile: gr.OAuthProfile = None,
|
| 215 |
):
|
| 216 |
"""Download the original slide file."""
|
| 217 |
+
username, _ = _get_username(request, profile, session_id)
|
| 218 |
|
| 219 |
if not username or not slide_id_or_name:
|
| 220 |
return gr.File(visible=False), gr.Textbox(
|
|
|
|
| 248 |
|
| 249 |
def delete_slide(
|
| 250 |
slide_id_or_name,
|
| 251 |
+
session_id: str = "",
|
| 252 |
request: gr.Request = None,
|
| 253 |
profile: gr.OAuthProfile = None,
|
| 254 |
):
|
| 255 |
"""Delete a slide and all associated results."""
|
| 256 |
+
username, _ = _get_username(request, profile, session_id)
|
| 257 |
|
| 258 |
if not username or not slide_id_or_name:
|
| 259 |
return gr.Textbox(
|
|
|
|
| 280 |
logger.error(f"Failed to delete slide {slide_id_or_name}: {e}")
|
| 281 |
return gr.Textbox(value=f"Error: {str(e)}", visible=True)
|
| 282 |
|
| 283 |
+
# Build inputs lists — include session_id_component when provided
|
| 284 |
+
# (HF Spaces) so _get_username can resolve the visitor's identity.
|
| 285 |
+
_sid = [session_id_component] if session_id_component is not None else []
|
| 286 |
+
|
| 287 |
# Wire up events
|
| 288 |
refresh_files_btn.click(
|
| 289 |
load_files,
|
| 290 |
+
inputs=_sid or None,
|
| 291 |
outputs=[
|
| 292 |
storage_usage_md,
|
| 293 |
files_table,
|
|
|
|
| 298 |
|
| 299 |
download_slide_btn.click(
|
| 300 |
download_slide,
|
| 301 |
+
inputs=[selected_slide_id] + _sid,
|
| 302 |
outputs=[slide_download_file, file_action_status],
|
| 303 |
)
|
| 304 |
|
| 305 |
delete_slide_btn.click(
|
| 306 |
delete_slide,
|
| 307 |
+
inputs=[selected_slide_id] + _sid,
|
| 308 |
outputs=[file_action_status],
|
| 309 |
).then(
|
| 310 |
load_files, # Refresh after delete
|
| 311 |
+
inputs=_sid or None,
|
| 312 |
outputs=[
|
| 313 |
storage_usage_md,
|
| 314 |
files_table,
|
|
|
|
| 340 |
}
|
| 341 |
|
| 342 |
|
| 343 |
+
def create_my_results_tab(session_id_component=None):
|
| 344 |
"""Create the My Results tab UI for browsing analysis results.
|
| 345 |
|
| 346 |
+
Args:
|
| 347 |
+
session_id_component: Hidden gr.Textbox carrying the localStorage
|
| 348 |
+
session ID (see ``create_my_files_tab`` docstring).
|
| 349 |
+
|
| 350 |
Returns:
|
| 351 |
Dictionary of UI components for event handlers
|
| 352 |
"""
|
|
|
|
| 395 |
)
|
| 396 |
result_download_file = gr.File(label="Download", visible=False)
|
| 397 |
|
| 398 |
+
def load_results(
|
| 399 |
+
session_id: str = "",
|
| 400 |
+
request: gr.Request = None,
|
| 401 |
+
profile: gr.OAuthProfile = None,
|
| 402 |
+
):
|
| 403 |
"""Load user's analysis results."""
|
| 404 |
+
username, _ = _get_username(request, profile, session_id)
|
| 405 |
|
| 406 |
if not username:
|
| 407 |
return [], gr.Textbox(visible=False), gr.File(visible=False)
|
|
|
|
| 438 |
)
|
| 439 |
|
| 440 |
def view_result_details(
|
| 441 |
+
analysis_id,
|
| 442 |
+
session_id: str = "",
|
| 443 |
+
request: gr.Request = None,
|
| 444 |
+
profile: gr.OAuthProfile = None,
|
| 445 |
):
|
| 446 |
"""Load and display result details."""
|
| 447 |
+
username, _ = _get_username(request, profile, session_id)
|
| 448 |
|
| 449 |
if not username or not analysis_id:
|
| 450 |
return (
|
|
|
|
| 553 |
)
|
| 554 |
|
| 555 |
def download_result_zip(
|
| 556 |
+
analysis_id,
|
| 557 |
+
session_id: str = "",
|
| 558 |
+
request: gr.Request = None,
|
| 559 |
+
profile: gr.OAuthProfile = None,
|
| 560 |
):
|
| 561 |
"""Create and download ZIP of result files."""
|
| 562 |
+
username, _ = _get_username(request, profile, session_id)
|
| 563 |
|
| 564 |
if not username or not analysis_id:
|
| 565 |
return gr.File(visible=False), gr.Textbox(
|
|
|
|
| 585 |
)
|
| 586 |
|
| 587 |
def delete_result(
|
| 588 |
+
analysis_id,
|
| 589 |
+
session_id: str = "",
|
| 590 |
+
request: gr.Request = None,
|
| 591 |
+
profile: gr.OAuthProfile = None,
|
| 592 |
):
|
| 593 |
"""Delete an analysis result."""
|
| 594 |
+
username, _ = _get_username(request, profile, session_id)
|
| 595 |
|
| 596 |
if not username or not analysis_id:
|
| 597 |
return gr.Textbox(
|
|
|
|
| 612 |
logger.error(f"Failed to delete result {analysis_id}: {e}")
|
| 613 |
return gr.Textbox(value=f"Error: {str(e)}", visible=True)
|
| 614 |
|
| 615 |
+
# Build inputs lists — include session_id_component when provided
|
| 616 |
+
_sid = [session_id_component] if session_id_component is not None else []
|
| 617 |
+
|
| 618 |
# Wire up events
|
| 619 |
refresh_results_btn.click(
|
| 620 |
load_results,
|
| 621 |
+
inputs=_sid or None,
|
| 622 |
outputs=[results_table, result_action_status, result_download_file],
|
| 623 |
)
|
| 624 |
|
| 625 |
view_result_btn.click(
|
| 626 |
view_result_details,
|
| 627 |
+
inputs=[selected_analysis_id] + _sid,
|
| 628 |
outputs=[
|
| 629 |
result_details,
|
| 630 |
result_metadata_md,
|
|
|
|
| 637 |
|
| 638 |
download_zip_btn.click(
|
| 639 |
download_result_zip,
|
| 640 |
+
inputs=[selected_analysis_id] + _sid,
|
| 641 |
outputs=[result_download_file, result_action_status],
|
| 642 |
)
|
| 643 |
|
| 644 |
delete_result_btn.click(
|
| 645 |
delete_result,
|
| 646 |
+
inputs=[selected_analysis_id] + _sid,
|
| 647 |
outputs=[result_action_status],
|
| 648 |
).then(
|
| 649 |
load_results, # Refresh after delete
|
| 650 |
+
inputs=_sid or None,
|
| 651 |
outputs=[results_table, result_action_status, result_download_file],
|
| 652 |
)
|
| 653 |
|
tests/telemetry/test_tracker.py
CHANGED
|
@@ -8,7 +8,6 @@ from pathlib import Path
|
|
| 8 |
import pytest
|
| 9 |
|
| 10 |
from mosaic.telemetry import TelemetryTracker, TelemetryConfig
|
| 11 |
-
from mosaic.telemetry.utils import hash_username
|
| 12 |
|
| 13 |
|
| 14 |
@pytest.fixture
|
|
@@ -153,7 +152,7 @@ class TestUsageEvents:
|
|
| 153 |
event = json.loads(f.read().strip())
|
| 154 |
|
| 155 |
assert event["is_logged_in"] is True
|
| 156 |
-
assert event["hf_username"] ==
|
| 157 |
|
| 158 |
def test_log_analysis_complete(self, tracker, temp_dir):
|
| 159 |
"""Test logging analysis complete event."""
|
|
@@ -287,7 +286,7 @@ class TestResourceEvents:
|
|
| 287 |
event = json.loads(f.read().strip())
|
| 288 |
|
| 289 |
assert event["is_logged_in"] is True
|
| 290 |
-
assert event["hf_username"] ==
|
| 291 |
|
| 292 |
|
| 293 |
class TestFailureEvents:
|
|
|
|
| 8 |
import pytest
|
| 9 |
|
| 10 |
from mosaic.telemetry import TelemetryTracker, TelemetryConfig
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
@pytest.fixture
|
|
|
|
| 152 |
event = json.loads(f.read().strip())
|
| 153 |
|
| 154 |
assert event["is_logged_in"] is True
|
| 155 |
+
assert event["hf_username"] == "testuser"
|
| 156 |
|
| 157 |
def test_log_analysis_complete(self, tracker, temp_dir):
|
| 158 |
"""Test logging analysis complete event."""
|
|
|
|
| 286 |
event = json.loads(f.read().strip())
|
| 287 |
|
| 288 |
assert event["is_logged_in"] is True
|
| 289 |
+
assert event["hf_username"] == "testuser"
|
| 290 |
|
| 291 |
|
| 292 |
class TestFailureEvents:
|
tests/telemetry/test_utils.py
CHANGED
|
@@ -8,7 +8,6 @@ from mosaic.telemetry.utils import (
|
|
| 8 |
StageTimer,
|
| 9 |
sanitize_error_message,
|
| 10 |
hash_session_id,
|
| 11 |
-
hash_username,
|
| 12 |
UserInfo,
|
| 13 |
extract_user_info,
|
| 14 |
)
|
|
@@ -151,40 +150,6 @@ class TestHashSessionId:
|
|
| 151 |
assert len(set(hashes)) == 1 # All hashes should be identical
|
| 152 |
|
| 153 |
|
| 154 |
-
class TestHashUsername:
|
| 155 |
-
"""Tests for username hashing."""
|
| 156 |
-
|
| 157 |
-
def test_hash_username(self):
|
| 158 |
-
"""Test basic username hashing."""
|
| 159 |
-
hashed = hash_username("testuser")
|
| 160 |
-
assert hashed is not None
|
| 161 |
-
assert hashed != "testuser"
|
| 162 |
-
assert len(hashed) == 16
|
| 163 |
-
|
| 164 |
-
def test_hash_none_returns_none(self):
|
| 165 |
-
"""Test that None input returns None."""
|
| 166 |
-
assert hash_username(None) is None
|
| 167 |
-
|
| 168 |
-
def test_hash_is_deterministic(self):
|
| 169 |
-
"""Test that same input produces same hash."""
|
| 170 |
-
hash1 = hash_username("alice")
|
| 171 |
-
hash2 = hash_username("alice")
|
| 172 |
-
assert hash1 == hash2
|
| 173 |
-
|
| 174 |
-
def test_different_inputs_different_hashes(self):
|
| 175 |
-
"""Test that different usernames produce different hashes."""
|
| 176 |
-
hash1 = hash_username("alice")
|
| 177 |
-
hash2 = hash_username("bob")
|
| 178 |
-
assert hash1 != hash2
|
| 179 |
-
|
| 180 |
-
def test_different_salt_from_session_id(self):
|
| 181 |
-
"""Test that username hash uses different salt than session hash."""
|
| 182 |
-
value = "same_value"
|
| 183 |
-
username_hash = hash_username(value)
|
| 184 |
-
session_hash = hash_session_id(value)
|
| 185 |
-
assert username_hash != session_hash
|
| 186 |
-
|
| 187 |
-
|
| 188 |
class TestUserInfo:
|
| 189 |
"""Tests for UserInfo dataclass."""
|
| 190 |
|
|
|
|
| 8 |
StageTimer,
|
| 9 |
sanitize_error_message,
|
| 10 |
hash_session_id,
|
|
|
|
| 11 |
UserInfo,
|
| 12 |
extract_user_info,
|
| 13 |
)
|
|
|
|
| 150 |
assert len(set(hashes)) == 1 # All hashes should be identical
|
| 151 |
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
class TestUserInfo:
|
| 154 |
"""Tests for UserInfo dataclass."""
|
| 155 |
|
tests/test_oauth.py
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the custom OAuth module (mosaic.ui.oauth)."""
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from unittest.mock import AsyncMock, MagicMock, patch
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from mosaic.ui.oauth import (
|
| 9 |
+
_prune_expired,
|
| 10 |
+
_sessions,
|
| 11 |
+
_pending_states,
|
| 12 |
+
_SESSION_COOKIE,
|
| 13 |
+
_SESSION_TTL_SEC,
|
| 14 |
+
_STATE_TTL_SEC,
|
| 15 |
+
create_oauth_lifespan,
|
| 16 |
+
get_user_by_session_id,
|
| 17 |
+
get_user_from_server_session,
|
| 18 |
+
mount_oauth_routes,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@pytest.fixture(autouse=True)
|
| 23 |
+
def clean_sessions():
|
| 24 |
+
"""Clear session and state stores before each test."""
|
| 25 |
+
_sessions.clear()
|
| 26 |
+
_pending_states.clear()
|
| 27 |
+
yield
|
| 28 |
+
_sessions.clear()
|
| 29 |
+
_pending_states.clear()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Session store tests
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class TestSessionStore:
|
| 38 |
+
"""Tests for the server-side session store."""
|
| 39 |
+
|
| 40 |
+
def test_store_and_retrieve_session(self):
|
| 41 |
+
"""Test basic session CRUD."""
|
| 42 |
+
_sessions["abc123"] = {
|
| 43 |
+
"userinfo": {"preferred_username": "alice"},
|
| 44 |
+
"created_at": time.time(),
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
request = MagicMock()
|
| 48 |
+
request.cookies = {_SESSION_COOKIE: "abc123"}
|
| 49 |
+
request.headers = {}
|
| 50 |
+
|
| 51 |
+
userinfo = get_user_from_server_session(request)
|
| 52 |
+
assert userinfo is not None
|
| 53 |
+
assert userinfo["preferred_username"] == "alice"
|
| 54 |
+
|
| 55 |
+
def test_missing_cookie_returns_none(self):
|
| 56 |
+
"""Test that missing cookie returns None."""
|
| 57 |
+
request = MagicMock()
|
| 58 |
+
request.cookies = {}
|
| 59 |
+
request.headers = {}
|
| 60 |
+
|
| 61 |
+
assert get_user_from_server_session(request) is None
|
| 62 |
+
|
| 63 |
+
def test_unknown_session_id_returns_none(self):
|
| 64 |
+
"""Test that unknown session ID returns None."""
|
| 65 |
+
request = MagicMock()
|
| 66 |
+
request.cookies = {_SESSION_COOKIE: "unknown"}
|
| 67 |
+
request.headers = {}
|
| 68 |
+
|
| 69 |
+
assert get_user_from_server_session(request) is None
|
| 70 |
+
|
| 71 |
+
def test_expired_session_returns_none(self):
|
| 72 |
+
"""Test that expired sessions are pruned."""
|
| 73 |
+
_sessions["expired"] = {
|
| 74 |
+
"userinfo": {"preferred_username": "bob"},
|
| 75 |
+
"created_at": time.time() - _SESSION_TTL_SEC - 1,
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
request = MagicMock()
|
| 79 |
+
request.cookies = {_SESSION_COOKIE: "expired"}
|
| 80 |
+
request.headers = {}
|
| 81 |
+
|
| 82 |
+
assert get_user_from_server_session(request) is None
|
| 83 |
+
assert "expired" not in _sessions
|
| 84 |
+
|
| 85 |
+
def test_cookie_from_header_fallback(self):
|
| 86 |
+
"""Test parsing cookie from raw Cookie header."""
|
| 87 |
+
_sessions["header_val"] = {
|
| 88 |
+
"userinfo": {"preferred_username": "carol"},
|
| 89 |
+
"created_at": time.time(),
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
request = MagicMock()
|
| 93 |
+
request.cookies = {} # Empty dict, fallback to header
|
| 94 |
+
request.headers = {"cookie": f"other=x; {_SESSION_COOKIE}=header_val; foo=bar"}
|
| 95 |
+
|
| 96 |
+
userinfo = get_user_from_server_session(request)
|
| 97 |
+
assert userinfo is not None
|
| 98 |
+
assert userinfo["preferred_username"] == "carol"
|
| 99 |
+
|
| 100 |
+
def test_none_request_returns_none(self):
|
| 101 |
+
"""Test that None request returns None gracefully."""
|
| 102 |
+
# get_user_from_server_session checks hasattr, so pass an object
|
| 103 |
+
# without cookies or headers
|
| 104 |
+
assert get_user_from_server_session(None) is None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ---------------------------------------------------------------------------
|
| 108 |
+
# get_user_by_session_id tests
|
| 109 |
+
# ---------------------------------------------------------------------------
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class TestGetUserBySessionId:
|
| 113 |
+
"""Tests for get_user_by_session_id (localStorage-based lookup)."""
|
| 114 |
+
|
| 115 |
+
def test_valid_session_id(self):
|
| 116 |
+
"""Test lookup with a valid session ID."""
|
| 117 |
+
_sessions["sid_abc"] = {
|
| 118 |
+
"userinfo": {"preferred_username": "alice"},
|
| 119 |
+
"created_at": time.time(),
|
| 120 |
+
}
|
| 121 |
+
result = get_user_by_session_id("sid_abc")
|
| 122 |
+
assert result is not None
|
| 123 |
+
assert result["preferred_username"] == "alice"
|
| 124 |
+
|
| 125 |
+
def test_empty_session_id(self):
|
| 126 |
+
"""Test that empty string returns None."""
|
| 127 |
+
assert get_user_by_session_id("") is None
|
| 128 |
+
|
| 129 |
+
def test_none_session_id(self):
|
| 130 |
+
"""Test that None returns None."""
|
| 131 |
+
assert get_user_by_session_id(None) is None
|
| 132 |
+
|
| 133 |
+
def test_unknown_session_id(self):
|
| 134 |
+
"""Test that unknown session ID returns None."""
|
| 135 |
+
assert get_user_by_session_id("nonexistent") is None
|
| 136 |
+
|
| 137 |
+
def test_expired_session_id(self):
|
| 138 |
+
"""Test that expired session returns None and is cleaned up."""
|
| 139 |
+
_sessions["expired_sid"] = {
|
| 140 |
+
"userinfo": {"preferred_username": "bob"},
|
| 141 |
+
"created_at": time.time() - _SESSION_TTL_SEC - 1,
|
| 142 |
+
}
|
| 143 |
+
assert get_user_by_session_id("expired_sid") is None
|
| 144 |
+
assert "expired_sid" not in _sessions
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
# Prune tests
|
| 149 |
+
# ---------------------------------------------------------------------------
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
class TestPruneExpired:
|
| 153 |
+
"""Tests for _prune_expired."""
|
| 154 |
+
|
| 155 |
+
def test_prune_expired_sessions(self):
|
| 156 |
+
"""Test that expired sessions are removed."""
|
| 157 |
+
_sessions["old"] = {
|
| 158 |
+
"userinfo": {"preferred_username": "old_user"},
|
| 159 |
+
"created_at": time.time() - _SESSION_TTL_SEC - 100,
|
| 160 |
+
}
|
| 161 |
+
_sessions["fresh"] = {
|
| 162 |
+
"userinfo": {"preferred_username": "new_user"},
|
| 163 |
+
"created_at": time.time(),
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
_prune_expired()
|
| 167 |
+
|
| 168 |
+
assert "old" not in _sessions
|
| 169 |
+
assert "fresh" in _sessions
|
| 170 |
+
|
| 171 |
+
def test_prune_expired_states(self):
|
| 172 |
+
"""Test that expired CSRF states are removed."""
|
| 173 |
+
_pending_states["old_state"] = time.time() - _STATE_TTL_SEC - 100
|
| 174 |
+
_pending_states["fresh_state"] = time.time()
|
| 175 |
+
|
| 176 |
+
_prune_expired()
|
| 177 |
+
|
| 178 |
+
assert "old_state" not in _pending_states
|
| 179 |
+
assert "fresh_state" in _pending_states
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# ---------------------------------------------------------------------------
|
| 183 |
+
# mount_oauth_routes tests
|
| 184 |
+
# ---------------------------------------------------------------------------
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class TestMountOAuthRoutes:
|
| 188 |
+
"""Tests for mount_oauth_routes."""
|
| 189 |
+
|
| 190 |
+
@patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "")
|
| 191 |
+
def test_no_routes_when_client_id_missing(self):
|
| 192 |
+
"""Test that routes are not mounted when OAUTH_CLIENT_ID is empty."""
|
| 193 |
+
app = MagicMock()
|
| 194 |
+
app.routes = []
|
| 195 |
+
|
| 196 |
+
mount_oauth_routes(app)
|
| 197 |
+
|
| 198 |
+
assert len(app.routes) == 0
|
| 199 |
+
|
| 200 |
+
@patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "test-client-id")
|
| 201 |
+
def test_routes_mounted_when_configured(self):
|
| 202 |
+
"""Test that routes are mounted when OAuth is configured."""
|
| 203 |
+
app = MagicMock()
|
| 204 |
+
app.routes = []
|
| 205 |
+
|
| 206 |
+
mount_oauth_routes(app)
|
| 207 |
+
|
| 208 |
+
assert len(app.routes) == 3
|
| 209 |
+
paths = [r.path for r in app.routes]
|
| 210 |
+
assert "/api/auth/login" in paths
|
| 211 |
+
assert "/api/auth/callback" in paths
|
| 212 |
+
assert "/api/auth/logout" in paths
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# ---------------------------------------------------------------------------
|
| 216 |
+
# Login route tests
|
| 217 |
+
# ---------------------------------------------------------------------------
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
class TestLoginRoute:
|
| 221 |
+
"""Tests for the /api/auth/login route."""
|
| 222 |
+
|
| 223 |
+
@patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "test-client-id")
|
| 224 |
+
@patch("mosaic.ui.oauth.SPACE_HOST", "user-space.hf.space")
|
| 225 |
+
def test_login_redirects_to_hf(self):
|
| 226 |
+
"""Test that login redirects to HF authorize URL."""
|
| 227 |
+
import asyncio
|
| 228 |
+
|
| 229 |
+
from mosaic.ui.oauth import _login
|
| 230 |
+
|
| 231 |
+
async def _run():
|
| 232 |
+
request = MagicMock()
|
| 233 |
+
return await _login(request)
|
| 234 |
+
|
| 235 |
+
response = asyncio.run(_run())
|
| 236 |
+
|
| 237 |
+
assert response.status_code == 302
|
| 238 |
+
location = response.headers["location"]
|
| 239 |
+
assert "huggingface.co/oauth/authorize" in location
|
| 240 |
+
assert "client_id=test-client-id" in location
|
| 241 |
+
assert "redirect_uri=" in location
|
| 242 |
+
assert "state=" in location
|
| 243 |
+
# Should have stored a CSRF state
|
| 244 |
+
assert len(_pending_states) == 1
|
| 245 |
+
|
| 246 |
+
@patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "")
|
| 247 |
+
def test_login_returns_error_when_not_configured(self):
|
| 248 |
+
"""Test that login returns error when OAuth is not configured."""
|
| 249 |
+
import asyncio
|
| 250 |
+
|
| 251 |
+
from mosaic.ui.oauth import _login
|
| 252 |
+
|
| 253 |
+
async def _run():
|
| 254 |
+
request = MagicMock()
|
| 255 |
+
return await _login(request)
|
| 256 |
+
|
| 257 |
+
response = asyncio.run(_run())
|
| 258 |
+
|
| 259 |
+
assert response.status_code == 500
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# ---------------------------------------------------------------------------
|
| 263 |
+
# Callback route tests
|
| 264 |
+
# ---------------------------------------------------------------------------
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class TestCallbackRoute:
|
| 268 |
+
"""Tests for the /api/auth/callback route."""
|
| 269 |
+
|
| 270 |
+
def test_callback_missing_params(self):
|
| 271 |
+
"""Test callback with missing code/state parameters."""
|
| 272 |
+
import asyncio
|
| 273 |
+
|
| 274 |
+
from mosaic.ui.oauth import _callback
|
| 275 |
+
|
| 276 |
+
async def _run():
|
| 277 |
+
request = MagicMock()
|
| 278 |
+
request.query_params = {}
|
| 279 |
+
return await _callback(request)
|
| 280 |
+
|
| 281 |
+
response = asyncio.run(_run())
|
| 282 |
+
assert response.status_code == 400
|
| 283 |
+
|
| 284 |
+
def test_callback_invalid_state(self):
|
| 285 |
+
"""Test callback with invalid CSRF state."""
|
| 286 |
+
import asyncio
|
| 287 |
+
|
| 288 |
+
from mosaic.ui.oauth import _callback
|
| 289 |
+
|
| 290 |
+
async def _run():
|
| 291 |
+
request = MagicMock()
|
| 292 |
+
request.query_params = {"code": "test-code", "state": "invalid-state"}
|
| 293 |
+
return await _callback(request)
|
| 294 |
+
|
| 295 |
+
response = asyncio.run(_run())
|
| 296 |
+
assert response.status_code == 400
|
| 297 |
+
|
| 298 |
+
@patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "test-client-id")
|
| 299 |
+
@patch("mosaic.ui.oauth.OAUTH_CLIENT_SECRET", "test-secret")
|
| 300 |
+
@patch("mosaic.ui.oauth.SPACE_HOST", "user-space.hf.space")
|
| 301 |
+
def test_callback_exchanges_code_for_token(self):
|
| 302 |
+
"""Test successful callback with mocked token exchange."""
|
| 303 |
+
import asyncio
|
| 304 |
+
import json
|
| 305 |
+
import base64
|
| 306 |
+
|
| 307 |
+
from mosaic.ui.oauth import _callback
|
| 308 |
+
|
| 309 |
+
# Set up a valid CSRF state
|
| 310 |
+
valid_state = "valid-state-token"
|
| 311 |
+
_pending_states[valid_state] = time.time()
|
| 312 |
+
|
| 313 |
+
# Create a mock id_token JWT
|
| 314 |
+
header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=").decode()
|
| 315 |
+
payload_data = json.dumps(
|
| 316 |
+
{"preferred_username": "visitor123", "sub": "visitor123"}
|
| 317 |
+
)
|
| 318 |
+
payload = base64.urlsafe_b64encode(payload_data.encode()).rstrip(b"=").decode()
|
| 319 |
+
mock_id_token = f"{header}.{payload}.fake_signature"
|
| 320 |
+
|
| 321 |
+
# Mock httpx — json() is a regular method (not async) on httpx.Response
|
| 322 |
+
mock_response = AsyncMock()
|
| 323 |
+
mock_response.json = MagicMock(
|
| 324 |
+
return_value={
|
| 325 |
+
"access_token": "test-access-token",
|
| 326 |
+
"id_token": mock_id_token,
|
| 327 |
+
}
|
| 328 |
+
)
|
| 329 |
+
mock_response.raise_for_status = MagicMock()
|
| 330 |
+
|
| 331 |
+
mock_client = AsyncMock()
|
| 332 |
+
mock_client.post.return_value = mock_response
|
| 333 |
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
| 334 |
+
mock_client.__aexit__ = AsyncMock(return_value=False)
|
| 335 |
+
|
| 336 |
+
request = MagicMock()
|
| 337 |
+
request.query_params = {"code": "auth-code", "state": valid_state}
|
| 338 |
+
|
| 339 |
+
async def _run():
|
| 340 |
+
with patch("httpx.AsyncClient", return_value=mock_client):
|
| 341 |
+
return await _callback(request)
|
| 342 |
+
|
| 343 |
+
response = asyncio.run(_run())
|
| 344 |
+
|
| 345 |
+
# Callback returns an HTML page that stores session in localStorage
|
| 346 |
+
assert response.status_code == 200
|
| 347 |
+
assert "localStorage.setItem" in response.body.decode()
|
| 348 |
+
assert "mosaic_session_id" in response.body.decode()
|
| 349 |
+
|
| 350 |
+
# Server-side session should be stored
|
| 351 |
+
assert len(_sessions) == 1
|
| 352 |
+
|
| 353 |
+
# CSRF state should be consumed
|
| 354 |
+
assert valid_state not in _pending_states
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
# ---------------------------------------------------------------------------
|
| 358 |
+
# Logout route tests
|
| 359 |
+
# ---------------------------------------------------------------------------
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
class TestLogoutRoute:
|
| 363 |
+
"""Tests for the /api/auth/logout route."""
|
| 364 |
+
|
| 365 |
+
def test_logout_clears_session(self):
|
| 366 |
+
"""Test that logout clears session data and returns localStorage-clearing HTML."""
|
| 367 |
+
import asyncio
|
| 368 |
+
|
| 369 |
+
from mosaic.ui.oauth import _logout
|
| 370 |
+
|
| 371 |
+
# Set up server-side session
|
| 372 |
+
_sessions["session_id_abc"] = {
|
| 373 |
+
"userinfo": {"preferred_username": "user"},
|
| 374 |
+
"created_at": time.time(),
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
request = MagicMock()
|
| 378 |
+
request.query_params = {"sid": "session_id_abc"}
|
| 379 |
+
|
| 380 |
+
async def _run():
|
| 381 |
+
return await _logout(request)
|
| 382 |
+
|
| 383 |
+
response = asyncio.run(_run())
|
| 384 |
+
|
| 385 |
+
# Returns HTML page that clears localStorage
|
| 386 |
+
assert response.status_code == 200
|
| 387 |
+
assert "localStorage.removeItem" in response.body.decode()
|
| 388 |
+
assert "mosaic_session_id" in response.body.decode()
|
| 389 |
+
assert "session_id_abc" not in _sessions
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
# ---------------------------------------------------------------------------
|
| 393 |
+
# get_user_from_server_session integration
|
| 394 |
+
# ---------------------------------------------------------------------------
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
class TestGetUserFromServerSession:
|
| 398 |
+
"""Integration tests for the server-session fallback."""
|
| 399 |
+
|
| 400 |
+
def test_works_with_gradio_request_like_object(self):
|
| 401 |
+
"""Test with an object mimicking gr.Request."""
|
| 402 |
+
_sessions["gr_session"] = {
|
| 403 |
+
"userinfo": {"preferred_username": "gradio_user", "name": "Test User"},
|
| 404 |
+
"created_at": time.time(),
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
class MockGradioRequest:
|
| 408 |
+
cookies = {_SESSION_COOKIE: "gr_session"}
|
| 409 |
+
headers = {}
|
| 410 |
+
|
| 411 |
+
result = get_user_from_server_session(MockGradioRequest())
|
| 412 |
+
assert result is not None
|
| 413 |
+
assert result["preferred_username"] == "gradio_user"
|
| 414 |
+
assert result["name"] == "Test User"
|
| 415 |
+
|
| 416 |
+
def test_returns_none_for_object_without_cookies(self):
|
| 417 |
+
"""Test graceful handling of objects without cookies."""
|
| 418 |
+
|
| 419 |
+
class Bare:
|
| 420 |
+
pass
|
| 421 |
+
|
| 422 |
+
assert get_user_from_server_session(Bare()) is None
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
# ---------------------------------------------------------------------------
|
| 426 |
+
# create_oauth_lifespan tests
|
| 427 |
+
# ---------------------------------------------------------------------------
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
class TestCreateOAuthLifespan:
|
| 431 |
+
"""Tests for the create_oauth_lifespan async context manager."""
|
| 432 |
+
|
| 433 |
+
@patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "test-client-id")
|
| 434 |
+
def test_routes_mounted_when_configured(self):
|
| 435 |
+
"""Test that OAuth routes are mounted on app when OAUTH_CLIENT_ID is set."""
|
| 436 |
+
import asyncio
|
| 437 |
+
|
| 438 |
+
app = MagicMock()
|
| 439 |
+
app.routes = []
|
| 440 |
+
|
| 441 |
+
async def _run():
|
| 442 |
+
async with create_oauth_lifespan(app):
|
| 443 |
+
assert len(app.routes) == 3
|
| 444 |
+
paths = [r.path for r in app.routes]
|
| 445 |
+
assert "/api/auth/login" in paths
|
| 446 |
+
assert "/api/auth/callback" in paths
|
| 447 |
+
assert "/api/auth/logout" in paths
|
| 448 |
+
|
| 449 |
+
asyncio.run(_run())
|
| 450 |
+
|
| 451 |
+
@patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "")
|
| 452 |
+
def test_routes_skipped_when_not_configured(self):
|
| 453 |
+
"""Test that no routes are mounted when OAUTH_CLIENT_ID is empty."""
|
| 454 |
+
import asyncio
|
| 455 |
+
|
| 456 |
+
app = MagicMock()
|
| 457 |
+
app.routes = []
|
| 458 |
+
|
| 459 |
+
async def _run():
|
| 460 |
+
async with create_oauth_lifespan(app):
|
| 461 |
+
assert len(app.routes) == 0
|
| 462 |
+
|
| 463 |
+
asyncio.run(_run())
|
| 464 |
+
|
| 465 |
+
@patch("mosaic.ui.oauth.OAUTH_CLIENT_ID", "test-client-id")
|
| 466 |
+
def test_routes_persist_after_yield(self):
|
| 467 |
+
"""Test that routes remain on the app after the lifespan exits."""
|
| 468 |
+
import asyncio
|
| 469 |
+
|
| 470 |
+
app = MagicMock()
|
| 471 |
+
app.routes = []
|
| 472 |
+
|
| 473 |
+
async def _run():
|
| 474 |
+
async with create_oauth_lifespan(app):
|
| 475 |
+
pass
|
| 476 |
+
# Routes should still be present after context manager exits
|
| 477 |
+
assert len(app.routes) == 3
|
| 478 |
+
|
| 479 |
+
asyncio.run(_run())
|
tests/test_ui_user_storage.py
CHANGED
|
@@ -86,16 +86,23 @@ class TestUsernameExtraction:
|
|
| 86 |
|
| 87 |
@patch("mosaic.ui.user_tabs.IS_HF_SPACES", True)
|
| 88 |
def test_hf_logged_in_returns_username(self, mock_request_hf_logged_in):
|
| 89 |
-
"""HF Spaces logged in should return
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
@patch("mosaic.ui.user_tabs.IS_HF_SPACES", True)
|
| 101 |
def test_hf_request_username_ignored(self, mock_request_hf_logged_in):
|
|
|
|
| 86 |
|
| 87 |
@patch("mosaic.ui.user_tabs.IS_HF_SPACES", True)
|
| 88 |
def test_hf_logged_in_returns_username(self, mock_request_hf_logged_in):
|
| 89 |
+
"""HF Spaces logged in should return username from session_id lookup."""
|
| 90 |
+
import time
|
| 91 |
+
from mosaic.ui.oauth import _sessions
|
| 92 |
+
|
| 93 |
+
# Set up a server-side session
|
| 94 |
+
_sessions["test_session_123"] = {
|
| 95 |
+
"userinfo": {"preferred_username": "test_user"},
|
| 96 |
+
"created_at": time.time(),
|
| 97 |
+
}
|
| 98 |
+
try:
|
| 99 |
+
username, is_local = _get_username(
|
| 100 |
+
mock_request_hf_logged_in, session_id="test_session_123"
|
| 101 |
+
)
|
| 102 |
+
assert username == "test_user"
|
| 103 |
+
assert is_local is False
|
| 104 |
+
finally:
|
| 105 |
+
_sessions.pop("test_session_123", None)
|
| 106 |
|
| 107 |
@patch("mosaic.ui.user_tabs.IS_HF_SPACES", True)
|
| 108 |
def test_hf_request_username_ignored(self, mock_request_hf_logged_in):
|
uv.lock
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|