Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions backend/tests/test_login_wake_return.py
Original file line number Diff line number Diff line change
@@ -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
87 changes: 86 additions & 1 deletion frontend/components/XamanLoginPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1058,6 +1117,9 @@ export function XamanLoginPanel() {
event.origin
);
postHeight();
if (consumeBackendWakeReturn(event.origin, nextLocale)) {
beginLoginRef.current();
}
return;
}

Expand Down Expand Up @@ -1331,6 +1393,19 @@ export function XamanLoginPanel() {
};
}, [clearCalorieAppSession, refreshCurrentUser]);

function handleLoginClick(event: MouseEvent<HTMLAnchorElement>) {
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();
Expand Down Expand Up @@ -1622,6 +1697,16 @@ export function XamanLoginPanel() {
>
Continue on CalorieToken.net
</a>
) : loginSurfaceMode === "embedded" && !isLoading ? (
<a
href={BACKEND_WAKE_NAVIGATION_URL ?? "#"}
target="_top"
referrerPolicy="no-referrer"
onClick={handleLoginClick}
className="mt-4 inline-flex items-center justify-center rounded-full bg-brand-primary px-6 py-2.5 text-sm font-semibold text-white transition hover:opacity-90"
>
Continue in Xaman
</a>
) : (
<button
type="button"
Expand Down
49 changes: 0 additions & 49 deletions frontend/lib/backendRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,53 +140,6 @@ async function discardResponseBody(response: Response) {
}
}

function mountBackendWakeDocument(backendBaseUrl: string): () => void {
const noop = () => {};
if (
typeof document === "undefined" ||
typeof window === "undefined" ||
!document.body
) {
return noop;
}

let backend: URL;
try {
backend = new URL(backendBaseUrl);
} catch {
return noop;
}
if (
backend.protocol !== "https:" ||
!backend.hostname.endsWith(".onrender.com") ||
backend.origin === window.location.origin ||
backend.username || backend.password || backend.port ||
backend.pathname !== "/" || backend.search || backend.hash
) {
return noop;
}

// A normal browser document can wake a sleeping Render service while
// background fetches receive startup responses. This makes one document
// request, with scripts and navigation disabled. Its load event is never
// treated as readiness: the regular health probes must still confirm JSON.
const frame = document.createElement("iframe");
frame.hidden = true;
frame.tabIndex = -1;
frame.title = "CalorieApp startup";
frame.referrerPolicy = "no-referrer";
frame.setAttribute("aria-hidden", "true");
frame.setAttribute("sandbox", "");
frame.src = `${backend.origin}/health`;
try {
document.body.appendChild(frame);
} catch {
frame.remove();
return noop;
}
return () => frame.remove();
}

/**
* Render free services can take 50 seconds or more to wake after inactivity.
* Probe one health route until the backend returns the expected JSON response,
Expand Down Expand Up @@ -290,7 +243,6 @@ export async function waitForBackendReady(
}

throwIfAborted(signal);
const removeWakeDocument = mountBackendWakeDocument(normalizedBaseUrl);
const directController = new AbortController();
const sameOriginController = new AbortController();
const abortBoth = () => {
Expand Down Expand Up @@ -320,7 +272,6 @@ export async function waitForBackendReady(
throw new BackendRequestTimeoutError();
} finally {
abortBoth();
removeWakeDocument();
signal?.removeEventListener("abort", abortBoth);
}
}
Expand Down
Loading