diff --git a/api/handler/session.go b/api/handler/session.go index 3baafe96..e8a81aa9 100644 --- a/api/handler/session.go +++ b/api/handler/session.go @@ -14,6 +14,10 @@ import ( "github.com/aws/aws-lambda-go/events" ) +// StatusOnlyQueryParam requests site status (notice/maintenance) without Turnstile or +// session cookie minting so the frontend can show banners before session bootstrap. +const StatusOnlyQueryParam = "statusOnly" + // TurnstileTokenQueryParam carries a one-time Cloudflare Turnstile response on GET /session. // Query string avoids a CORS preflight for a custom header; API Gateway OPTIONS does not // forward preflights to Lambda with our current CORS wiring. @@ -36,6 +40,10 @@ func Session(ctx context.Context, request events.APIGatewayProxyRequest) (events return res, nil } + if isStatusOnlyRequest(request) { + return statusOnlyResponse(apiRes, origin) + } + if config.APISessionSecret() == "" { return errorResponse(apiRes, origin, "session not configured", http.StatusServiceUnavailable) } @@ -105,6 +113,28 @@ func enforceTurnstile( return apiRes, true } +func isStatusOnlyRequest(request events.APIGatewayProxyRequest) bool { + if request.HTTPMethod != http.MethodGet || request.QueryStringParameters == nil { + return false + } + value := strings.TrimSpace(request.QueryStringParameters[StatusOnlyQueryParam]) + return value == "1" || strings.EqualFold(value, "true") +} + +func statusOnlyResponse( + apiRes events.APIGatewayProxyResponse, + origin string, +) (events.APIGatewayProxyResponse, error) { + apiRes, err := jsonResponse(apiRes, origin, http.StatusOK, buildSiteStatusResponse()) + if err != nil { + return errorResponse(apiRes, origin, "err marshalling response", http.StatusInternalServerError) + } + applyMaintenanceHeaders(&apiRes) + headers := ensureResponseHeaders(&apiRes) + headers["Cache-Control"] = "public, max-age=60" + return apiRes, nil +} + var ( errSessionVerificationRequired = errors.New("verification required") errSessionMethodNotAllowed = errors.New("method not allowed") diff --git a/api/handler/session_test.go b/api/handler/session_test.go index 28329f02..51dddffc 100644 --- a/api/handler/session_test.go +++ b/api/handler/session_test.go @@ -124,6 +124,30 @@ func TestSession_RejectsFailedTurnstileVerification(t *testing.T) { require.Equal(t, "verification failed", payload.Error) } +func TestSession_StatusOnly_SkipsTurnstileAndCookieMint(t *testing.T) { + t.Setenv(config.APISessionSecretEnv, "test-session-secret") + t.Setenv(config.TurnstileSecretKeyEnv, "test-turnstile-secret") + t.Setenv(config.APINoticeMessageEnv, "Card Kingdom prices may be delayed today.") + + req := events.APIGatewayProxyRequest{ + HTTPMethod: http.MethodGet, + Headers: map[string]string{ + "origin": "http://localhost:5173", + }, + QueryStringParameters: map[string]string{ + StatusOnlyQueryParam: "1", + }, + } + + res, err := Session(context.Background(), req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + require.Empty(t, res.Headers["Set-Cookie"]) + require.Equal(t, "Card Kingdom prices may be delayed today.", res.Headers[noticeMessageHeader]) + require.Contains(t, res.Body, `"noticeMessage":"Card Kingdom prices may be delayed today."`) + require.Equal(t, "public, max-age=60", res.Headers["Cache-Control"]) +} + func TestSession_RejectsPostWhenTurnstileConfigured(t *testing.T) { t.Setenv(config.APISessionSecretEnv, "test-session-secret") t.Setenv(config.TurnstileSecretKeyEnv, "test-turnstile-secret") diff --git a/docs/api-abuse-mitigation.md b/docs/api-abuse-mitigation.md index 29709149..f0ca3b38 100644 --- a/docs/api-abuse-mitigation.md +++ b/docs/api-abuse-mitigation.md @@ -209,6 +209,19 @@ cannot search when this layer is on. Token format: `expiryUnix.nonce.hmac` (HMAC-SHA256 over `expiry.nonce` with `API_SESSION_SECRET`). +### Site status probe (`GET /session?statusOnly=1`) + +The SPA fetches notice and maintenance banners with **`GET /session?statusOnly=1`** +before Turnstile and session minting complete. This path: + +1. Passes origin verification (layer 1) when configured. +2. Skips Turnstile and does **not** mint a session cookie (even when + `TURNSTILE_SECRET_KEY` / `API_SESSION_SECRET` are set). +3. Returns the same JSON site status and `X-Maintenance-*` / `X-Notice-Message` + headers as a successful mint, with `Cache-Control: public, max-age=60`. + +No API Gateway route change is required — it uses the existing `/session` route. + ### Enforcement (`GET /search`) When `API_SESSION_SECRET` is set, `/search` requires a valid cookie: diff --git a/frontend/index.html b/frontend/index.html index dcc73e81..5ef641ac 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -43,6 +43,7 @@ + diff --git a/frontend/src/constants.js b/frontend/src/constants.js index c46bac48..9eaf1c71 100644 --- a/frontend/src/constants.js +++ b/frontend/src/constants.js @@ -223,6 +223,9 @@ export const TURNSTILE_SITE_KEY = /** Query param sent with GET /session when Turnstile is enabled. */ export const TURNSTILE_TOKEN_QUERY_PARAM = "turnstileToken"; +/** Query param for GET /session that returns notice/maintenance without Turnstile. */ +export const STATUS_ONLY_QUERY_PARAM = "statusOnly"; + export const BASE_URL = "https://gishathfetch.com/"; /** Deep link that opens the main privacy policy modal on the site. */ diff --git a/frontend/src/hooks/useSearch.js b/frontend/src/hooks/useSearch.js index 16033b2c..7f79ed42 100644 --- a/frontend/src/hooks/useSearch.js +++ b/frontend/src/hooks/useSearch.js @@ -10,7 +10,10 @@ import { import { API_SESSION_REFRESH_INTERVAL_MS, ensureApiSession, + fetchSiteStatus, formatSessionBootstrapError, + getCachedSessionBootstrap, + getCachedSiteStatus, isApiSessionAccessDenied, resetApiSessionCache, } from "../utils/apiSession"; @@ -41,7 +44,23 @@ const AUTOCOMPLETE_DEBOUNCE_MS = 300; const SEARCH_PROGRESS_INTERVAL_MS = 1000; const MAX_PROGRESS_DOTS = 15; +function readLandingSearchQuery() { + const urlParams = new URLSearchParams(window.location.search); + if (!urlParams.has("s") || urlParams.get("s") === "") { + return null; + } + + const query = decodeURIComponent(urlParams.get("s")); + if (query.length < MIN_SEARCH_LENGTH || query.length > MAX_SEARCH_LENGTH) { + return null; + } + + return query; +} + export default function useSearch() { + const initialSiteStatus = getCachedSiteStatus(); + const initialBootstrap = getCachedSessionBootstrap(); const [searchQuery, setSearchQuery] = useState(() => { const urlParams = new URLSearchParams(window.location.search); if (urlParams.has("s") && urlParams.get("s") !== "") { @@ -67,10 +86,18 @@ export default function useSearch() { const [cardKingdomPrice, setCardKingdomPrice] = useState(null); const [dismissedStoreErrorsKey, setDismissedStoreErrorsKey] = useState(null); const [storesWarning, setStoresWarning] = useState(null); - const [maintenanceMode, setMaintenanceMode] = useState(false); - const [maintenanceMessage, setMaintenanceMessage] = useState(""); - const [noticeMessage, setNoticeMessage] = useState(""); - const [sessionBootstrapped, setSessionBootstrapped] = useState(false); + const [maintenanceMode, setMaintenanceMode] = useState(() => + Boolean(initialSiteStatus?.maintenanceMode), + ); + const [maintenanceMessage, setMaintenanceMessage] = useState( + () => initialSiteStatus?.maintenanceMessage ?? "", + ); + const [noticeMessage, setNoticeMessage] = useState( + () => initialSiteStatus?.noticeMessage ?? "", + ); + const [sessionBootstrapped, setSessionBootstrapped] = useState( + () => initialBootstrap !== null, + ); const [selectedStores, setSelectedStores] = useState(() => getInitialSelectedStores(), ); @@ -90,23 +117,59 @@ export default function useSearch() { const skipHistorySyncRef = useRef(false); const restoringHistoryRef = useRef(false); const performSearchRef = useRef(() => {}); + const landingSearchHandledRef = useRef(false); useEffect(() => { searchResultsRef.current = searchResults; }, [searchResults]); + const runLandingSearchIfNeeded = useCallback((timing) => { + if (landingSearchHandledRef.current || timing?.maintenanceMode) { + return; + } + + const query = readLandingSearchQuery(); + if (!query) { + landingSearchHandledRef.current = true; + return; + } + + landingSearchHandledRef.current = true; + skipSuggestionsRef.current = true; + + const urlParams = new URLSearchParams(window.location.search); + const urlStores = getStoresFromUrl(urlParams); + const stores = urlStores ?? getInitialSelectedStores(urlParams); + performSearchRef.current(query, stores); + }, []); + useEffect(() => { let cancelled = false; + const applySiteStatus = (status) => { + setMaintenanceMode(Boolean(status.maintenanceMode)); + setMaintenanceMessage(status.maintenanceMessage ?? ""); + setNoticeMessage(status.noticeMessage ?? ""); + }; + + fetchSiteStatus() + .then((status) => { + if (!cancelled) { + applySiteStatus(status); + } + }) + .catch(() => { + // Notice is optional; session bootstrap may still provide status later. + }); + ensureApiSession() .then((timing) => { if (cancelled) { return; } - setMaintenanceMode(Boolean(timing.maintenanceMode)); - setMaintenanceMessage(timing.maintenanceMessage ?? ""); - setNoticeMessage(timing.noticeMessage ?? ""); + applySiteStatus(timing); setSessionBootstrapped(true); + runLandingSearchIfNeeded(timing); }) .catch((err) => { if (!cancelled) { @@ -116,15 +179,17 @@ export default function useSearch() { }); const refreshTimer = setInterval(() => { - ensureApiSession({ forceRefresh: true }) - .then((timing) => { - setMaintenanceMode(Boolean(timing.maintenanceMode)); - setMaintenanceMessage(timing.maintenanceMessage ?? ""); - setNoticeMessage(timing.noticeMessage ?? ""); + fetchSiteStatus({ forceRefresh: true }) + .then((siteStatus) => { + applySiteStatus(siteStatus); }) .catch(() => { - // Next search or interval will try again. + // Next interval will try again. }); + + ensureApiSession({ forceRefresh: true }).catch(() => { + // Next search or interval will try again. + }); }, API_SESSION_REFRESH_INTERVAL_MS); return () => { @@ -745,40 +810,12 @@ export default function useSearch() { persistSelectedStores(stores); }, []); - // --- Initialization --- - // Run a deep-linked ?s= search once session bootstrap (incl. Turnstile) completes. - const landingSearchHandledRef = useRef(false); - - const runLandingSearchIfNeeded = useCallback(() => { - if (landingSearchHandledRef.current || maintenanceMode) { - return; - } - - const urlParams = new URLSearchParams(window.location.search); - if (!urlParams.has("s") || urlParams.get("s") === "") { - landingSearchHandledRef.current = true; - return; - } - - const q = decodeURIComponent(urlParams.get("s")); - if (q.length < MIN_SEARCH_LENGTH || q.length > MAX_SEARCH_LENGTH) { - landingSearchHandledRef.current = true; - return; - } - - landingSearchHandledRef.current = true; - skipSuggestionsRef.current = true; - - const urlStores = getStoresFromUrl(urlParams); - const stores = urlStores ?? getInitialSelectedStores(urlParams); - performSearch(q, stores); - }, [maintenanceMode, performSearch]); - + // Fallback when bootstrap completed before this hook subscribed (e.g. fast remount). useEffect(() => { if (!sessionBootstrapped) { return; } - runLandingSearchIfNeeded(); + runLandingSearchIfNeeded(getCachedSessionBootstrap()); }, [sessionBootstrapped, runLandingSearchIfNeeded]); return { diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index fdce2565..5818aac7 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2,6 +2,16 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App.jsx"; +import { TURNSTILE_SITE_KEY } from "./constants"; +import { ensureApiSession, fetchSiteStatus } from "./utils/apiSession"; +import { isTurnstileEnabled, preloadTurnstile } from "./utils/turnstile"; + +// Fetch notice/maintenance immediately (no Turnstile). Overlap session mint with hydration. +void fetchSiteStatus(); +if (isTurnstileEnabled(TURNSTILE_SITE_KEY)) { + void preloadTurnstile(TURNSTILE_SITE_KEY); +} +void ensureApiSession(); createRoot(document.getElementById("root")).render( diff --git a/frontend/src/utils/apiSession.js b/frontend/src/utils/apiSession.js index f52fa57e..69182af0 100644 --- a/frontend/src/utils/apiSession.js +++ b/frontend/src/utils/apiSession.js @@ -1,5 +1,6 @@ import { API_SESSION_URL, + STATUS_ONLY_QUERY_PARAM, TURNSTILE_SITE_KEY, TURNSTILE_TOKEN_QUERY_PARAM, } from "../constants"; @@ -14,10 +15,15 @@ const NOTICE_MESSAGE_HEADER = "X-Notice-Message"; /** * @typedef {{ + * maintenanceMode: boolean, + * maintenanceMessage: string, + * noticeMessage: string, + * }} SiteStatus + */ + +/** + * @typedef {SiteStatus & { * sessionMintDurationMs: number, - * maintenanceMode?: boolean, - * maintenanceMessage?: string, - * noticeMessage?: string, * }} SessionBootstrapTiming */ @@ -28,6 +34,46 @@ export const API_SESSION_REFRESH_INTERVAL_MS = 10 * 60 * 1000; const SESSION_MINT_MAX_ATTEMPTS = 3; let sessionBootstrapPromise = null; +/** @type {SessionBootstrapTiming | null} */ +let cachedSessionBootstrap = null; +let siteStatusPromise = null; +/** @type {SiteStatus | null} */ +let cachedSiteStatus = null; + +/** Resolved bootstrap timing when session mint has completed (for fast UI hydration). */ +export function getCachedSessionBootstrap() { + return cachedSessionBootstrap; +} + +/** Resolved site status when the status-only probe has completed. */ +export function getCachedSiteStatus() { + return cachedSiteStatus; +} + +/** + * Fetches notice/maintenance without Turnstile or session cookies so banners can + * render before session bootstrap completes. + * + * @returns {Promise} + */ +export async function fetchSiteStatus(options = {}) { + const { forceRefresh = false } = options; + + if (forceRefresh) { + siteStatusPromise = null; + } + + if (!siteStatusPromise) { + siteStatusPromise = loadSiteStatus().catch((err) => { + siteStatusPromise = null; + throw err; + }); + } + + const status = await siteStatusPromise; + cachedSiteStatus = status; + return status; +} export function parseMaintenanceFromSessionResponse(res) { if (res.headers.get(MAINTENANCE_MODE_HEADER) !== "1") { @@ -138,12 +184,14 @@ export async function ensureApiSession(options = {}) { try { const bootstrapTiming = await sessionBootstrapPromise; + cachedSessionBootstrap = bootstrapTiming; if (!initiatedBootstrap) { return joinedBootstrapTiming(bootstrapTiming); } return bootstrapTiming; } catch (err) { sessionBootstrapPromise = null; + cachedSessionBootstrap = null; throw err; } } @@ -170,6 +218,22 @@ async function bootstrapSessionWithRetry() { throw lastError ?? new Error("API session failed"); } +async function loadSiteStatus() { + const params = new URLSearchParams({ + [STATUS_ONLY_QUERY_PARAM]: "1", + }); + const res = await fetch(`${API_SESSION_URL}?${params.toString()}`, { + method: "GET", + credentials: "omit", + }); + + if (!res.ok) { + throw new Error(`Site status failed (${res.status})`); + } + + return parseSiteStatusFromSession(res); +} + async function mintApiSession() { // Network failures surface as TypeError; rethrow as-is so the UI keeps the // accurate "unable to connect" copy instead of blaming session verification. @@ -226,6 +290,13 @@ export function isApiSessionAccessDenied(message, statusCode) { /** Clears the cached bootstrap promise (for tests or after auth errors). */ export function resetApiSessionCache() { sessionBootstrapPromise = null; + cachedSessionBootstrap = null; +} + +/** Clears cached site status (for tests). */ +export function resetSiteStatusCache() { + siteStatusPromise = null; + cachedSiteStatus = null; } /** User-facing copy when the initial session bootstrap fails. */ diff --git a/frontend/src/utils/turnstile.js b/frontend/src/utils/turnstile.js index 04ff462d..bfbf978b 100644 --- a/frontend/src/utils/turnstile.js +++ b/frontend/src/utils/turnstile.js @@ -1,4 +1,4 @@ -const TURNSTILE_SCRIPT_URL = +export const TURNSTILE_SCRIPT_URL = "https://challenges.cloudflare.com/turnstile/v0/api.js"; const TURNSTILE_TIMEOUT_MS = 30_000; @@ -60,6 +60,19 @@ function ensureWidget(siteKey) { return widgetId; } +/** + * Loads the Turnstile script and pre-renders the invisible widget so the first + * session mint does not wait on script download during React hydration. + */ +export async function preloadTurnstile(siteKey) { + if (!isTurnstileEnabled(siteKey)) { + return; + } + + await loadTurnstileScript(); + ensureWidget(siteKey.trim()); +} + /** * Runs invisible Turnstile and returns a one-time token for GET /session * (sent as the turnstileToken query param). Safe to call on every session mint diff --git a/frontend/src/utils/turnstile.test.js b/frontend/src/utils/turnstile.test.js index 96fb29c1..5fa03cc3 100644 --- a/frontend/src/utils/turnstile.test.js +++ b/frontend/src/utils/turnstile.test.js @@ -1,6 +1,17 @@ import assert from "node:assert/strict"; -import { isTurnstileEnabled } from "./turnstile.js"; +import { + isTurnstileEnabled, + preloadTurnstile, + TURNSTILE_SCRIPT_URL, +} from "./turnstile.js"; assert.equal(isTurnstileEnabled(""), false); assert.equal(isTurnstileEnabled(" "), false); assert.equal(isTurnstileEnabled("1x00000000000000000000AA"), true); +assert.equal( + TURNSTILE_SCRIPT_URL, + "https://challenges.cloudflare.com/turnstile/v0/api.js", +); + +await preloadTurnstile(""); +await preloadTurnstile(" ");