feat(SEC-001): protect workspace bootstrap with turnstile
This commit is contained in:
@@ -63,8 +63,7 @@ def legacy_backtests_page(request: Request):
|
||||
def workspace_backtests_page(workspace_id: str) -> None:
|
||||
repo = get_workspace_repository()
|
||||
if not repo.workspace_exists(workspace_id):
|
||||
render_workspace_recovery()
|
||||
return
|
||||
return RedirectResponse(url="/", status_code=307)
|
||||
_render_backtests_page(workspace_id=workspace_id)
|
||||
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ def render_workspace_recovery(title: str = "Workspace not found", message: str |
|
||||
ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50")
|
||||
ui.label(resolved_message).classes("text-base text-slate-500 dark:text-slate-400")
|
||||
with ui.row().classes("mx-auto gap-3"):
|
||||
ui.link("Get started", "/workspaces/bootstrap").classes(
|
||||
ui.link("Get started", "/").classes(
|
||||
"rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900"
|
||||
)
|
||||
ui.link("Go to welcome page", "/").classes(
|
||||
|
||||
@@ -41,8 +41,7 @@ def legacy_event_comparison_page(request: Request):
|
||||
def workspace_event_comparison_page(workspace_id: str) -> None:
|
||||
repo = get_workspace_repository()
|
||||
if not repo.workspace_exists(workspace_id):
|
||||
render_workspace_recovery()
|
||||
return
|
||||
return RedirectResponse(url="/", status_code=307)
|
||||
_render_event_comparison_page(workspace_id=workspace_id)
|
||||
|
||||
|
||||
|
||||
@@ -70,8 +70,7 @@ def legacy_hedge_page(request: Request):
|
||||
def workspace_hedge_page(workspace_id: str) -> None:
|
||||
repo = get_workspace_repository()
|
||||
if not repo.workspace_exists(workspace_id):
|
||||
render_workspace_recovery()
|
||||
return
|
||||
return RedirectResponse(url="/", status_code=307)
|
||||
_render_hedge_page(workspace_id=workspace_id)
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
|
||||
from app.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog
|
||||
from app.services.alerts import AlertService, build_portfolio_alert_context
|
||||
from app.services.runtime import get_data_service
|
||||
from app.services.turnstile import load_turnstile_settings
|
||||
|
||||
_DEFAULT_CASH_BUFFER = 18_500.0
|
||||
|
||||
@@ -55,7 +56,7 @@ def _render_workspace_recovery(title: str, message: str) -> None:
|
||||
ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50")
|
||||
ui.label(message).classes("text-base text-slate-500 dark:text-slate-400")
|
||||
with ui.row().classes("mx-auto gap-3"):
|
||||
ui.link("Get started", "/workspaces/bootstrap").classes(
|
||||
ui.link("Get started", "/").classes(
|
||||
"rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900"
|
||||
)
|
||||
ui.link("Go to welcome page", "/").classes(
|
||||
@@ -69,6 +70,7 @@ def welcome_page(request: Request):
|
||||
workspace_id = request.cookies.get(WORKSPACE_COOKIE, "")
|
||||
if workspace_id and repo.workspace_exists(workspace_id):
|
||||
return RedirectResponse(url=f"/{workspace_id}", status_code=307)
|
||||
captcha_error = request.query_params.get("captcha_error") == "1"
|
||||
|
||||
with ui.column().classes("mx-auto mt-24 w-full max-w-3xl gap-8 px-6"):
|
||||
with ui.card().classes(
|
||||
@@ -79,10 +81,25 @@ def welcome_page(request: Request):
|
||||
ui.label(
|
||||
"Start with a workspace-scoped overview and settings area. Your portfolio defaults are stored server-side and your browser keeps a workspace cookie for quick return visits."
|
||||
).classes("text-base text-slate-500 dark:text-slate-400")
|
||||
with ui.row().classes("items-center gap-4 pt-4"):
|
||||
ui.link("Get started", "/workspaces/bootstrap").classes(
|
||||
"rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900"
|
||||
if captcha_error:
|
||||
ui.label("CAPTCHA verification failed. Please retry the Turnstile challenge.").classes(
|
||||
"rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/30 dark:text-rose-300"
|
||||
)
|
||||
with ui.row().classes("items-center gap-4 pt-4"):
|
||||
turnstile = load_turnstile_settings()
|
||||
ui.add_body_html(
|
||||
'<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>'
|
||||
)
|
||||
hidden_token = (
|
||||
'<input type="hidden" name="cf-turnstile-response" value="test-token" />'
|
||||
if turnstile.uses_test_keys
|
||||
else ""
|
||||
)
|
||||
ui.html(f"""<form method="post" action="/workspaces/bootstrap" class="flex items-center gap-4">
|
||||
{hidden_token}
|
||||
<div class="cf-turnstile" data-sitekey="{turnstile.site_key}"></div>
|
||||
<button type="submit" class="rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900">Get started</button>
|
||||
</form>""")
|
||||
ui.label("You can always create a fresh workspace later if a link is lost.").classes(
|
||||
"text-sm text-slate-500 dark:text-slate-400"
|
||||
)
|
||||
@@ -102,11 +119,7 @@ def legacy_overview_page(request: Request):
|
||||
async def overview_page(workspace_id: str) -> None:
|
||||
repo = get_workspace_repository()
|
||||
if not repo.workspace_exists(workspace_id):
|
||||
_render_workspace_recovery(
|
||||
"Workspace not found",
|
||||
"The workspace link looks missing or expired. Create a new workspace or return to the welcome page.",
|
||||
)
|
||||
return
|
||||
return RedirectResponse(url="/", status_code=307)
|
||||
|
||||
config = repo.load_portfolio_config(workspace_id)
|
||||
data_service = get_data_service()
|
||||
@@ -224,15 +237,17 @@ async def overview_page(workspace_id: str) -> None:
|
||||
"w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-slate-800"
|
||||
):
|
||||
with ui.column().classes("gap-1"):
|
||||
ui.label(event.message).classes("text-sm font-medium text-slate-900 dark:text-slate-100")
|
||||
ui.label(event.message).classes(
|
||||
"text-sm font-medium text-slate-900 dark:text-slate-100"
|
||||
)
|
||||
ui.label(
|
||||
f"Logged {_format_timestamp(event.updated_at)} · Spot ${event.spot_price:,.2f} · LTV {event.ltv_ratio:.1%}"
|
||||
).classes("text-xs text-slate-500 dark:text-slate-400")
|
||||
ui.label(event.severity.upper()).classes(_alert_badge_classes(event.severity))
|
||||
else:
|
||||
ui.label("No alert history yet. Alerts will be logged once the warning threshold is crossed.").classes(
|
||||
"text-sm text-slate-500 dark:text-slate-400"
|
||||
)
|
||||
ui.label(
|
||||
"No alert history yet. Alerts will be logged once the warning threshold is crossed."
|
||||
).classes("text-sm text-slate-500 dark:text-slate-400")
|
||||
|
||||
with ui.card().classes(
|
||||
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||
|
||||
@@ -27,7 +27,7 @@ def _render_workspace_recovery() -> None:
|
||||
"The requested workspace is unavailable. Start a new workspace or return to the welcome page."
|
||||
).classes("text-base text-slate-500 dark:text-slate-400")
|
||||
with ui.row().classes("mx-auto gap-3"):
|
||||
ui.link("Get started", "/workspaces/bootstrap").classes(
|
||||
ui.link("Get started", "/").classes(
|
||||
"rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900"
|
||||
)
|
||||
ui.link("Go to welcome page", "/").classes(
|
||||
@@ -49,8 +49,7 @@ def settings_page(workspace_id: str) -> None:
|
||||
"""Settings page with workspace-scoped persistent portfolio configuration."""
|
||||
workspace_repo = get_workspace_repository()
|
||||
if not workspace_repo.workspace_exists(workspace_id):
|
||||
_render_workspace_recovery()
|
||||
return
|
||||
return RedirectResponse(url="/", status_code=307)
|
||||
|
||||
config = workspace_repo.load_portfolio_config(workspace_id)
|
||||
alert_service = AlertService()
|
||||
|
||||
Reference in New Issue
Block a user