From c1773ceb54b873278bdacc1183d1fc864eac51b7 Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:42:14 +0200 Subject: [PATCH] Resume the existing login after a top-level backend startup request --- backend/app/main.py | 14 +- backend/tests/test_login_wake_return.py | 40 ++++ frontend/components/XamanLoginPanel.tsx | 87 +++++++- frontend/lib/backendRequest.ts | 49 ----- tools/tests/backend_wake_navigation.test.mjs | 194 ++++++++++++++++++ .../tests/backend_warmup_rate_limit.test.mjs | 89 +------- 6 files changed, 333 insertions(+), 140 deletions(-) create mode 100644 backend/tests/test_login_wake_return.py create mode 100644 tools/tests/backend_wake_navigation.test.mjs diff --git a/backend/app/main.py b/backend/app/main.py index 891a797..77ad635 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,6 +13,7 @@ import httpx from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Query, Request, Response from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import RedirectResponse from httpx import HTTPError from pydantic import ValidationError from sqlalchemy import delete, update @@ -817,8 +818,17 @@ def _exchange_code_for_claims(code: str, state: str) -> IdentityClaimsResponse: raise HTTPException(status_code=502, detail="Bridge identity claims were malformed") from exc -@app.get("/health") -def health() -> dict[str, str]: +@app.get("/health", response_model=None) +def health(resume_login: bool = False) -> Response | dict[str, str]: + if resume_login: + # A top-level browser request can pass Render's startup page. Once + # this process is serving requests, return to the fixed website page. + # This creates no login state and accepts no caller-supplied redirect. + return RedirectResponse( + f"{_WORDPRESS_URL.rstrip('/')}/index.php/calorieapp/", + status_code=303, + headers={"Cache-Control": "no-store", "Pragma": "no-cache"}, + ) return { "status": "ok", "service": "calorieapp-backend", diff --git a/backend/tests/test_login_wake_return.py b/backend/tests/test_login_wake_return.py new file mode 100644 index 0000000..3bd1dbe --- /dev/null +++ b/backend/tests/test_login_wake_return.py @@ -0,0 +1,40 @@ +"""A public startup navigation returns only to the configured website.""" + +from fastapi.testclient import TestClient + +import app.main as main + + +def test_wake_returns_to_website_without_creating_a_session( + client: TestClient, monkeypatch +) -> None: + monkeypatch.setattr(main, "_WORDPRESS_URL", "https://calorietoken.net") + response = client.get("/health?resume_login=true", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "https://calorietoken.net/index.php/calorieapp/" + assert response.headers["cache-control"] == "no-store" + assert response.headers["x-frame-options"] == "DENY" + assert response.headers["referrer-policy"] == "no-referrer" + assert "set-cookie" not in response.headers + + +def test_wake_ignores_supplied_redirect_and_authentication_values( + client: TestClient, monkeypatch +) -> None: + monkeypatch.setattr(main, "_WORDPRESS_URL", "https://calorietoken.net/") + response = client.get( + "/health?resume_login=true&redirect=https://attacker.example/" + "&state=untrusted&code=untrusted&return_to=//attacker.example/", + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["location"] == "https://calorietoken.net/index.php/calorieapp/" + assert "set-cookie" not in response.headers + + +def test_normal_health_probe_remains_json(client: TestClient) -> None: + for path in ("/health", "/health?resume_login=false"): + response = client.get(path, follow_redirects=False) + assert response.status_code == 200 + assert response.json()["status"] == "ok" + assert "location" not in response.headers diff --git a/frontend/components/XamanLoginPanel.tsx b/frontend/components/XamanLoginPanel.tsx index 42f62b1..daa615d 100644 --- a/frontend/components/XamanLoginPanel.tsx +++ b/frontend/components/XamanLoginPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react"; import { AccountDataExportButton } from "@/components/AccountDataExportButton"; import { AccountDataImportPanel } from "@/components/AccountDataImportPanel"; import { AccountErasurePanel } from "@/components/AccountErasurePanel"; @@ -76,12 +76,71 @@ const LOGIN_COOKIE_CONFIRMATION_RETRY_WINDOW_MS = 10_000; const MAX_EMBEDDED_AUTHORIZATION_REFRESHES = 2; const PENDING_LOGIN_STORAGE_KEY = "calorieapp-pending-xaman-login"; const LOGIN_RETURN_STORAGE_KEY = "calorieapp-login-return"; +const BACKEND_WAKE_RETURN_KEY = "calorieapp-backend-wake-return"; +const BACKEND_WAKE_RETURN_MAX_AGE_MS = 5 * 60_000; const BRIDGE_STATE_ALREADY_CONSUMED_MESSAGE = "State is unknown, expired, or already used"; const WORDPRESS_APP_URL = process.env.NEXT_PUBLIC_WORDPRESS_APP_URL?.trim() || "https://calorietoken.net/index.php/calorieapp/"; +export function backendWakeNavigationUrl(baseUrl: string): string | null { + try { + const backend = new URL(baseUrl); + if ( + backend.protocol !== "https:" || + !backend.hostname.endsWith(".onrender.com") || + backend.username || backend.password || backend.port || + backend.pathname !== "/" || backend.search || backend.hash + ) { + return null; + } + return `${backend.origin}/health?resume_login=true`; + } catch { + return null; + } +} + +export function rememberBackendWakeReturn( + parentOrigin: string, + locale: string +): boolean { + try { + window.sessionStorage.setItem(BACKEND_WAKE_RETURN_KEY, JSON.stringify({ + parentOrigin, + locale, + startedAt: Date.now(), + })); + return true; + } catch { + return false; + } +} + +export function consumeBackendWakeReturn( + parentOrigin: string, + locale: string +): boolean { + try { + const raw = window.sessionStorage.getItem(BACKEND_WAKE_RETURN_KEY); + // Consume before validation so duplicate bridge messages cannot start + // another login and an expired or malformed marker cannot cause a loop. + window.sessionStorage.removeItem(BACKEND_WAKE_RETURN_KEY); + if (!raw) return false; + const pending = JSON.parse(raw); + const age = Date.now() - pending.startedAt; + return ( + typeof pending.startedAt === "number" && + Number.isFinite(age) && age >= 0 && age < BACKEND_WAKE_RETURN_MAX_AGE_MS && + pending.parentOrigin === parentOrigin && pending.locale === locale + ); + } catch { + return false; + } +} + +const BACKEND_WAKE_NAVIGATION_URL = backendWakeNavigationUrl(BACKEND_WAKE_BASE_URL); + type ParentBridgeMessage = { type?: unknown; requestId?: unknown; @@ -1058,6 +1117,9 @@ export function XamanLoginPanel() { event.origin ); postHeight(); + if (consumeBackendWakeReturn(event.origin, nextLocale)) { + beginLoginRef.current(); + } return; } @@ -1331,6 +1393,19 @@ export function XamanLoginPanel() { }; }, [clearCalorieAppSession, refreshCurrentUser]); + function handleLoginClick(event: MouseEvent) { + if ( + parentOrigin.current && BACKEND_WAKE_NAVIGATION_URL && + rememberBackendWakeReturn(parentOrigin.current, activeLocale.current) + ) { + // Preserve the native, user-activated top-level navigation. A hidden + // document or background fetch is not equivalent on a sleeping service. + return; + } + event.preventDefault(); + void handleLogin(); + } + async function handleLogin() { const controller = new AbortController(); loginAbortController.current?.abort(); @@ -1622,6 +1697,16 @@ export function XamanLoginPanel() { > Continue on CalorieToken.net + ) : loginSurfaceMode === "embedded" && !isLoading ? ( + + Continue in Xaman + ) : (