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
30 changes: 30 additions & 0 deletions api/handler/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
Expand Down Expand Up @@ -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")
Expand Down
24 changes: 24 additions & 0 deletions api/handler/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
13 changes: 13 additions & 0 deletions docs/api-abuse-mitigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@

<!-- Preconnect to external domains for better performance -->
<link rel="preconnect" href="https://challenges.cloudflare.com">
<link rel="preload" href="https://challenges.cloudflare.com/turnstile/v0/api.js" as="script">
<link rel="preconnect" href="https://api.scryfall.com">
<link rel="preconnect" href="https://pagead2.googlesyndication.com">
<link rel="preconnect" href="https://fonts.googleapis.com">
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
123 changes: 80 additions & 43 deletions frontend/src/hooks/useSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
import {
API_SESSION_REFRESH_INTERVAL_MS,
ensureApiSession,
fetchSiteStatus,
formatSessionBootstrapError,
getCachedSessionBootstrap,
getCachedSiteStatus,
isApiSessionAccessDenied,
resetApiSessionCache,
} from "../utils/apiSession";
Expand Down Expand Up @@ -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") !== "") {
Expand All @@ -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(),
);
Expand All @@ -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) {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<StrictMode>
Expand Down
Loading
Loading