Spaces:
Running
Running
| """Module entrypoint for SupraDashboard.""" | |
| from __future__ import annotations | |
| import os | |
| # gradio launch() self-checks reachability with `networking.url_ok(self.local_url)`, | |
| # where local_url is http://0.0.0.0:7860/ because we bind server_name="0.0.0.0" (required | |
| # so HF's router can reach the container). httpx.head() to 0.0.0.0 is not connectable from | |
| # inside the container, so url_ok returns False and launch() raises "When localhost is not | |
| # accessible…", crashing startup. The server itself binds and serves fine — only this | |
| # non-essential pre-flight fails — so we neutralize it (and exempt loopback from the proxy). | |
| for _k in ("NO_PROXY", "no_proxy"): | |
| _cur = os.environ.get(_k, "") | |
| _need = "localhost,127.0.0.1,0.0.0.0" | |
| os.environ[_k] = f"{_cur},{_need}" if _cur else _need | |
| import gradio.networking # noqa: E402 | |
| gradio.networking.url_ok = lambda url: True # HF binds 0.0.0.0; the self-check is moot here | |
| from ui.app import _auth, build_ui # noqa: E402 — after the launch self-check patch | |
| # Gradio's built-in login page is served BEFORE our app theme/CSS load, so it falls back to | |
| # the browser's dark mode (black card, invisible username/password text). `auth_message` is | |
| # injected as raw HTML on that page — use it to force a light color-scheme and readable inputs | |
| # matching the bone-white app theme. | |
| _LOGIN_MESSAGE = """ | |
| <style> | |
| /* The built-in login page renders with `body.dark` (it follows the browser's OS | |
| color-scheme, BEFORE our app theme can flip it). Gradio paints the login card | |
| (.block), inputs and labels from CSS custom properties that, INSIDE the `.dark` | |
| scope, resolve to dark stone values (e.g. --block-background-fill: #292524) — so | |
| hardcoding bg on body/input/button alone loses to those vars and the card stays | |
| black. The robust fix is to REDEFINE the custom properties themselves (under both | |
| :root and .dark, with !important) to bone-light values, then belt-and-suspenders | |
| paint the concrete elements. This is CSS-only (a <script> wouldn't run via | |
| innerHTML), and it makes the card + username/password labels readable in dark OS. */ | |
| :root, .dark, body.dark { | |
| color-scheme: light !important; | |
| --body-background-fill: #F4EFE6 !important; | |
| --body-text-color: #2A2622 !important; | |
| --body-text-color-subdued: #6B6258 !important; | |
| --background-fill-primary: #FBF8F1 !important; | |
| --background-fill-secondary: #F4EFE6 !important; | |
| --block-background-fill: #FBF8F1 !important; | |
| --block-label-background-fill: #FBF8F1 !important; | |
| --panel-background-fill: #FBF8F1 !important; | |
| --block-border-color: #2A262220 !important; | |
| --border-color-primary: #2A262222 !important; | |
| --block-label-text-color: #2A2622 !important; | |
| --block-title-text-color: #2A2622 !important; | |
| --block-info-text-color: #6B6258 !important; | |
| --input-background-fill: #FBF8F1 !important; | |
| --input-border-color: #2A262233 !important; | |
| --input-placeholder-color: #6B6258 !important; | |
| --button-primary-background-fill: #1E7A74 !important; | |
| --button-primary-background-fill-hover: #155954 !important; | |
| --button-primary-text-color: #FBF8F1 !important; | |
| --bg: #F4EFE6 !important; --bg-dark: #F4EFE6 !important; | |
| --col: #2A2622 !important; --col-dark: #2A2622 !important; | |
| } | |
| html, body, .gradio-container, gradio-app, .dark, .app, .main, .wrap, .panel, | |
| .column, .form { | |
| background: #F4EFE6 !important; color: #2A2622 !important; | |
| } | |
| /* The login card itself — a Gradio .block — must be bone, not dark stone. */ | |
| .block, .block.padded { | |
| background: #FBF8F1 !important; | |
| border: 1px solid #2A262220 !important; border-radius: 12px !important; | |
| } | |
| input, textarea { | |
| background: #FBF8F1 !important; color: #2A2622 !important; | |
| -webkit-text-fill-color: #2A2622 !important; | |
| border: 1px solid #2A262233 !important; border-radius: 8px !important; | |
| } | |
| input::placeholder { color: #6B6258 !important; -webkit-text-fill-color: #6B6258 !important; } | |
| h1, h2, h3, p, label, label span, [data-testid="block-info"], | |
| .label, .form span, .container span { | |
| color: #2A2622 !important; | |
| } | |
| button, [type="submit"], button.primary { | |
| background: #1E7A74 !important; color: #FBF8F1 !important; border: none !important; | |
| border-radius: 10px !important; font-weight: 600 !important; | |
| } | |
| button:hover, button.primary:hover { background: #155954 !important; } | |
| </style> | |
| <div style="font-family:'Hanken Grotesk',ui-sans-serif,sans-serif;color:#2A2622; | |
| text-align:center;font-size:14px;line-height:1.5;margin:2px 0 8px"> | |
| <strong>SupraDashboard</strong> · Host-Guest Review | |
| </div> | |
| """ | |
| def _build_app(): | |
| """Mount the Gradio app on a FastAPI app fronted by a brute-force guard: | |
| a per-IP failed-login lockout on the /login endpoint (Gradio's built-in auth | |
| has no rate limiting, so a public Space is otherwise brute-forceable).""" | |
| import time | |
| from collections import defaultdict | |
| import gradio as gr | |
| from fastapi import FastAPI, Request | |
| from starlette.responses import PlainTextResponse | |
| # Tunable via Space variables; defaults: 5 fails / 5 min window -> 15 min lock. | |
| max_fails = int(os.environ.get("LOGIN_MAX_FAILS", "5")) | |
| window = int(os.environ.get("LOGIN_FAIL_WINDOW", "300")) | |
| lockout = int(os.environ.get("LOGIN_LOCKOUT", "900")) | |
| fails: dict[str, list[float]] = defaultdict(list) | |
| locked: dict[str, float] = {} | |
| def client_ip(request: Request) -> str: | |
| # HF's router proxies the container, so the real client IP is the first | |
| # entry of X-Forwarded-For; fall back to the socket peer locally. | |
| xff = request.headers.get("x-forwarded-for") | |
| if xff: | |
| return xff.split(",")[0].strip() | |
| return request.client.host if request.client else "unknown" | |
| app = FastAPI() | |
| async def login_guard(request: Request, call_next): | |
| is_login = request.method == "POST" and request.url.path.rstrip("/").endswith("/login") | |
| if not is_login: | |
| return await call_next(request) | |
| ip = client_ip(request) | |
| now = time.time() | |
| until = locked.get(ip, 0.0) | |
| if until > now: | |
| return PlainTextResponse( | |
| f"Too many failed login attempts. Try again in {int(until - now)}s.", | |
| status_code=429, | |
| ) | |
| resp = await call_next(request) | |
| if resp.status_code >= 400: # Gradio returns 400 on bad credentials | |
| recent = [t for t in fails[ip] if now - t < window] | |
| recent.append(now) | |
| fails[ip] = recent | |
| if len(recent) >= max_fails: | |
| locked[ip] = now + lockout | |
| fails[ip] = [] | |
| else: # successful login clears the IP's record | |
| fails.pop(ip, None) | |
| locked.pop(ip, None) | |
| return resp | |
| demo = build_ui().queue(default_concurrency_limit=1, api_open=False) | |
| return gr.mount_gradio_app( | |
| app, demo, path="/", auth=_auth(), auth_message=_LOGIN_MESSAGE, | |
| ) | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run(_build_app(), host="0.0.0.0", port=int(os.environ.get("PORT", 7860))) | |