mirror of
https://github.com/stellarshenson/stellars-jupyterhub-ds.git
synced 2026-03-09 06:30:29 +00:00
84 lines
4.9 KiB
Markdown
84 lines
4.9 KiB
Markdown
# Custom Branding
|
|
|
|
Custom logo and favicon for the JupyterHub platform, controlled via environment variables. Both support local files (`file://` prefix) and external URLs (`http(s)://`). Empty value uses stock JupyterHub assets.
|
|
|
|
## Environment Variables
|
|
|
|
| Variable | Purpose | Values |
|
|
|----------|---------|--------|
|
|
| `JUPYTERHUB_LOGO_URI` | Hub login and navigation logo | `file:///path/to/logo.svg` or `https://example.com/logo.svg` |
|
|
| `JUPYTERHUB_FAVICON_URI` | Browser tab favicon for hub and JupyterLab | `file:///path/to/favicon.ico` or `https://example.com/favicon.ico` |
|
|
|
|
**`file://` handling**: The file is copied to JupyterHub's static directory at startup, then served via `static_url()`. The source file must be accessible inside the container (mount via compose volumes).
|
|
|
|
**URL handling**: External URLs are passed directly to templates for rendering.
|
|
|
|
## Favicon in JupyterLab Sessions
|
|
|
|
Hub pages serve the custom favicon directly. JupyterLab sessions present a challenge because favicon requests (`/user/{username}/static/favicons/favicon.ico`) are routed by Configurable HTTP Proxy (CHP) to the user container, bypassing the hub entirely. The user container serves JupyterLab's default favicon.
|
|
|
|
### Solution - CHP Proxy Route Injection
|
|
|
|
The platform uses CHP's trie-based longest-prefix-match routing to intercept favicon requests before they reach user containers.
|
|
|
|
**How it works**:
|
|
|
|
1. `pre_spawn_hook` registers a per-user CHP route (`/user/{username}/static/favicons/`) pointing back to the hub
|
|
2. CHP's longest-prefix-match selects this over the generic `/user/{username}/` route
|
|
3. A `FaviconRedirectHandler` (injected into the Tornado app's wildcard router) responds with a 302 redirect to the hub's static favicon
|
|
|
|
**Request flow**:
|
|
|
|
```
|
|
Browser: GET /user/alice/static/favicons/favicon.ico
|
|
-> CHP: longest-prefix matches /user/alice/static/favicons/ -> hub (host:port)
|
|
-> Hub: FaviconRedirectHandler -> 302 /hub/static/favicon.ico
|
|
-> Browser follows redirect, hub serves custom favicon
|
|
```
|
|
|
|
### Technical Details - Two Pitfalls
|
|
|
|
Two non-obvious issues required specific solutions during implementation:
|
|
|
|
**CHP target must be host:port only (no path)**. `app.hub.url` returns `http://jupyterhub:8080/hub/` which includes a `/hub/` path. When a CHP target has a path component, CHP rewrites the forwarded request path - stripping the matched route prefix and prepending the target path. This causes the hub to receive a mangled path that no handler matches. The fix uses `urlparse` to extract just `scheme://netloc` from `app.hub.url`, matching how JupyterHub registers its own hub route (`/ -> http://jupyterhub:8080`).
|
|
|
|
**Tornado handler must be inserted into the existing wildcard router**. `app.tornado_application.add_handlers(".*", ...)` creates a new host group that Tornado checks after all existing host groups. Since JupyterHub's default handlers include catch-all patterns, the new group is never reached. The fix uses `app.tornado_application.wildcard_router.rules.insert(0, rule)` to prepend the handler rule into the existing host group, ensuring it's checked before any catch-all.
|
|
|
|
### Why not `extra_handlers`?
|
|
|
|
JupyterHub auto-prefixes all `extra_handlers` routes with `/hub/`. CHP forwards the original path without `/hub/`, so the handler would never match. Instead, `FaviconRedirectHandler` extends `tornado.web.RequestHandler` (not `BaseHandler`) and is injected directly into the Tornado app's wildcard router.
|
|
|
|
### Route Lifecycle
|
|
|
|
- **New spawns**: `pre_spawn_hook` registers per-user CHP route before each spawn (idempotent)
|
|
- **Surviving servers**: A one-shot `IOLoop.current().add_callback()` startup callback iterates all active servers and registers their CHP routes immediately after the event loop starts - this covers servers that were already running when JupyterHub restarted
|
|
- Tornado handler is injected once (guarded by `app._favicon_handler_injected` flag) by whichever path executes first
|
|
- Stale routes when servers stop are harmless (hub is always running to handle them)
|
|
- No cleanup needed
|
|
|
|
### Conditionality
|
|
|
|
The entire mechanism only activates when `JUPYTERHUB_FAVICON_URI` is non-empty. When empty, JupyterLab sessions display their own default favicon.
|
|
|
|
## Implementation Files
|
|
|
|
| File | Role |
|
|
|------|------|
|
|
| `config/jupyterhub_config.py` | Favicon file copy at startup, CHP route + Tornado handler injection in `pre_spawn_hook` |
|
|
| `services/jupyterhub/conf/bin/custom_handlers.py` | `FaviconRedirectHandler` class (extends `tornado.web.RequestHandler`) |
|
|
| `services/jupyterhub/templates/page.html` | Conditional favicon rendering in `<head>` |
|
|
|
|
## Deployment Example
|
|
|
|
```yaml
|
|
# compose_override.yml
|
|
services:
|
|
jupyterhub:
|
|
volumes:
|
|
- ./favicon.ico:/srv/jupyterhub/favicon.ico:ro
|
|
- ./logo.svg:/srv/jupyterhub/logo.svg:ro
|
|
environment:
|
|
- JUPYTERHUB_FAVICON_URI=file:///srv/jupyterhub/favicon.ico
|
|
- JUPYTERHUB_LOGO_URI=file:///srv/jupyterhub/logo.svg
|
|
```
|