From 89a5c540653db7bca4ef381adf678b9aeb191dd3 Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Sat, 13 Jun 2026 19:27:51 +0800 Subject: [PATCH] fix(auth): address PR review and OAuth state-loss bugs - proxy: await getUser() so refreshed session cookies land on the response - callback: gate on AUTH_ENABLED, reject non-relative next (open redirect) - page: snapshot + resume form and style image across the OAuth redirect; require login before the style-image vision parse - play: wire authResolveRef so login retries the action that hit 401; dismissing the modal no longer re-fires it - server: wrap cookie setAll in try/catch for read-only contexts Co-Authored-By: Claude Fable 5 --- app/auth/callback/route.ts | 19 +++- app/page.tsx | 192 +++++++++++++++++++++++++++++++++---- app/play/page.tsx | 50 +++++++--- lib/supabase/server.ts | 10 +- proxy.ts | 7 +- 5 files changed, 237 insertions(+), 41 deletions(-) diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index 92e0bfd..a365673 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -1,10 +1,27 @@ import { type NextRequest, NextResponse } from "next/server"; +import { AUTH_ENABLED } from "@/lib/supabase/config"; import { createClient } from "@/lib/supabase/server"; +// Only allow same-origin relative paths. Rejects `//evil.com`, `/\evil.com`, +// and absolute URLs that would otherwise turn `${origin}${next}` into an +// open redirect (CWE-601). +function safeNext(raw: string | null): string { + if (!raw || !raw.startsWith("/")) return "/"; + if (raw.startsWith("//") || raw.startsWith("/\\")) return "/"; + return raw; +} + export async function GET(request: NextRequest) { const { searchParams, origin } = request.nextUrl; + + // Auth not configured: nothing can legitimately hit this route, so just + // bounce home instead of constructing a Supabase client from blank env vars. + if (!AUTH_ENABLED) { + return NextResponse.redirect(`${origin}/`); + } + const code = searchParams.get("code"); - const next = searchParams.get("next") ?? "/"; + const next = safeNext(searchParams.get("next")); if (code) { const supabase = await createClient(); diff --git a/app/page.tsx b/app/page.tsx index b73d4e0..b7e4d3d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -851,6 +851,51 @@ function CategorySelect({ /* ---------- style picker modal ---------- */ +const PENDING_START_KEY = "infiplot:pending-start"; +const PENDING_PARSE_KEY = "infiplot:pending-parse"; + +// True when auth is disabled (self-host with blank Supabase env) or the visitor +// already has a session. Gates the vision call behind login. +async function isAuthed(): Promise { + if (!AUTH_ENABLED) return true; + const sb = createSupabaseClient(); + const { data } = await sb.auth.getUser(); + return !!data.user; +} + +// Shared by the StyleModal uploader and the post-login resume path: turns a +// resized data URL into an English style prompt, via the browser engine when a +// BYO model config is present, otherwise the server route. +async function extractStylePromptFromImage(resized: string): Promise { + const modelCfg = readStoredModelConfig(); + if (modelCfg) { + const config = resolveEngineConfig(modelCfg, null); + const raw = await analyzeImageDataUrl( + config.vision, + resized, + STYLE_EXTRACTION_PROMPT, + ); + let parsed: { stylePrompt?: string }; + try { + parsed = JSON.parse(raw); + } catch { + parsed = { stylePrompt: raw }; + } + return (parsed.stylePrompt ?? "").trim(); + } + const r = await fetch("/api/parse-style-image", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ imageDataUrl: resized }), + }); + if (!r.ok) { + const data = (await r.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error || `HTTP ${r.status}`); + } + const data = (await r.json()) as { stylePrompt?: string }; + return (data.stylePrompt ?? "").trim(); +} + function StyleModal({ items, value, @@ -860,6 +905,7 @@ function StyleModal({ setCustomStyleGuide, customStyleRefImage, setCustomStyleRefImage, + onRequireAuth, }: { items: string[]; value: number; @@ -869,6 +915,7 @@ function StyleModal({ setCustomStyleGuide: (s: string) => void; customStyleRefImage: string; setCustomStyleRefImage: (s: string) => void; + onRequireAuth: () => void; }) { const [q, setQ] = useState(""); const [shown, setShown] = useState(false); @@ -983,31 +1030,18 @@ function StyleModal({ setParsing(true); try { const resized = await resizeImageToDataUrl(file); - const modelCfg = readStoredModelConfig(); - let stylePrompt: string; - if (modelCfg) { - const config = resolveEngineConfig(modelCfg, null); - const raw = await analyzeImageDataUrl(config.vision, resized, STYLE_EXTRACTION_PROMPT); - let parsed: { stylePrompt?: string }; + // The parse is a paid vision call, so require login first. The resize is + // already done — stash it so login can auto-resume the parse on return. + if (!(await isAuthed())) { try { - parsed = JSON.parse(raw); + sessionStorage.setItem(PENDING_PARSE_KEY, resized); } catch { - parsed = { stylePrompt: raw }; + /* too big to stash — user re-uploads after login */ } - stylePrompt = (parsed.stylePrompt ?? "").trim(); - } else { - const r = await fetch("/api/parse-style-image", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ imageDataUrl: resized }), - }); - if (!r.ok) { - const data = await r.json().catch(() => ({})); - throw new Error(data.error || `HTTP ${r.status}`); - } - const data = (await r.json()) as { stylePrompt?: string }; - stylePrompt = (data.stylePrompt ?? "").trim(); + onRequireAuth(); + return; } + const stylePrompt = await extractStylePromptFromImage(resized); if (!stylePrompt) throw new Error("视觉模型返回了空的风格描述"); setDraft(stylePrompt); setCustomStyleRefImage(resized); @@ -1356,11 +1390,112 @@ export default function HomePage() { } }; + // ── Auth-gated resume (OAuth round-trips lose all React state) ────────── + // An OAuth login unmounts the homepage and discards everything the user + // typed. We snapshot the form before redirecting and replay it on return. + // The email-OTP path keeps state in place and resumes synchronously via + // AuthModal.onSuccess instead. + const [autoStartPending, setAutoStartPending] = useState(false); + + const persistPendingStart = () => { + const snap = { prompt, sel, customStyleGuide, customStyleRefImage, playerName }; + try { + sessionStorage.setItem(PENDING_START_KEY, JSON.stringify(snap)); + } catch { + // Quota is usually blown by the data-URL style ref; drop it, keep text. + try { + sessionStorage.setItem( + PENDING_START_KEY, + JSON.stringify({ ...snap, customStyleRefImage: "" }), + ); + } catch { + /* still too big — give up on resume, the form just clears */ + } + } + }; + + const resumePendingParse = async () => { + const resized = sessionStorage.getItem(PENDING_PARSE_KEY); + if (!resized) return; + sessionStorage.removeItem(PENDING_PARSE_KEY); + try { + const stylePrompt = await extractStylePromptFromImage(resized); + if (!stylePrompt) return; + setCustomStyleGuide(stylePrompt); + setCustomStyleRefImage(resized); + const customIdx = ART_STYLES.indexOf("自定义风格"); + if (styleRow >= 0 && customIdx >= 0) { + setSel((s) => s.map((v, j) => (j === styleRow ? customIdx : v))); + } + track("style_image_upload", { ok: true }); + } catch { + /* resume parse failed — stay silent, user can re-upload */ + } + }; + + const resumePendingStart = () => { + const raw = sessionStorage.getItem(PENDING_START_KEY); + if (!raw) return; + sessionStorage.removeItem(PENDING_START_KEY); + try { + const snap = JSON.parse(raw) as { + prompt?: string; + sel?: number[]; + customStyleGuide?: string; + customStyleRefImage?: string; + playerName?: string; + }; + setPrompt(snap.prompt ?? ""); + if (Array.isArray(snap.sel)) setSel(snap.sel); + setCustomStyleGuide(snap.customStyleGuide ?? ""); + setCustomStyleRefImage(snap.customStyleRefImage ?? ""); + if (snap.playerName) setPlayerName(snap.playerName); + // Defer start() to the next render so it reads the restored state. + setAutoStartPending(true); + } catch { + /* corrupt snapshot — ignore */ + } + }; + + // On mount after an OAuth redirect: if a pending action was left and the user + // is now signed in, restore and continue; otherwise clear stale snapshots. + useEffect(() => { + if (!AUTH_ENABLED) return; + const hasPending = + sessionStorage.getItem(PENDING_START_KEY) !== null || + sessionStorage.getItem(PENDING_PARSE_KEY) !== null; + if (!hasPending) return; + let cancelled = false; + void (async () => { + if (!(await isAuthed())) { + sessionStorage.removeItem(PENDING_START_KEY); + sessionStorage.removeItem(PENDING_PARSE_KEY); + return; + } + if (cancelled) return; + await resumePendingParse(); + resumePendingStart(); + })(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Run the resumed start() only after restored form state has committed. + useEffect(() => { + if (!autoStartPending) return; + setAutoStartPending(false); + void start(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoStartPending]); + const start = async () => { if (AUTH_ENABLED) { const sb = createSupabaseClient(); const { data } = await sb.auth.getUser(); if (!data.user) { + persistPendingStart(); setPendingAction("start"); setAuthModalOpen(true); return; @@ -1811,6 +1946,7 @@ export default function HomePage() { setCustomStyleGuide={setCustomStyleGuide} customStyleRefImage={customStyleRefImage} setCustomStyleRefImage={setCustomStyleRefImage} + onRequireAuth={() => setAuthModalOpen(true)} /> )} {settingsOpen && ( @@ -1835,11 +1971,25 @@ export default function HomePage() { onClose={() => { setAuthModalOpen(false); setPendingAction(null); + try { + sessionStorage.removeItem(PENDING_START_KEY); + sessionStorage.removeItem(PENDING_PARSE_KEY); + } catch { + /* ignore */ + } }} onSuccess={() => { setAuthModalOpen(false); + // Email-OTP stays on the page, so resume inline: parse first (it + // reads its own snapshot), then the pending start. + void resumePendingParse(); if (pendingAction === "start") { setPendingAction(null); + try { + sessionStorage.removeItem(PENDING_START_KEY); + } catch { + /* ignore */ + } start(); } }} diff --git a/app/play/page.tsx b/app/play/page.tsx index 076fb0d..32b5633 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -548,13 +548,20 @@ function PlayInner() { { done: number; total: number; label: string } | null >(null); - const handleAuthError = useCallback((e: unknown): boolean => { - if (e instanceof AuthRequiredError) { - setAuthModalOpen(true); - return true; - } - return false; - }, []); + // `retry` re-runs the action that hit the 401, replayed by AuthModal.onSuccess + // after the user signs in. Omitted by callers whose path can't actually 401 + // (initial load already gated on the homepage, recorded replay is local). + const handleAuthError = useCallback( + (e: unknown, retry?: () => void): boolean => { + if (e instanceof AuthRequiredError) { + authResolveRef.current = retry ?? null; + setAuthModalOpen(true); + return true; + } + return false; + }, + [], + ); const startedRef = useRef(false); const poolRef = useRef>(new Map()); @@ -1436,6 +1443,7 @@ function PlayInner() { exit: SceneExit, visitedForCurrent: string[], exitLabel: string, + retry?: () => void, ) { setPhase("transitioning"); setPendingClick(null); @@ -1493,7 +1501,7 @@ function PlayInner() { setPhase("ready"); return; } - if (!handleAuthError(e)) setError(String(e)); + if (!handleAuthError(e, retry)) setError(String(e)); setPhase("ready"); } } @@ -1681,7 +1689,9 @@ function PlayInner() { const cached = consumeChoice(poolRef.current, choice.id); if (cached) { - void performSceneTransition(cached, exit, visited, choice.label); + void performSceneTransition(cached, exit, visited, choice.label, () => + onSelectChoice(choice), + ); return; } @@ -1706,7 +1716,9 @@ function PlayInner() { return data; })(); - void performSceneTransition(promise, exit, visited, choice.label); + void performSceneTransition(promise, exit, visited, choice.label, () => + onSelectChoice(choice), + ); } async function onFreeformInput(text: string) { @@ -1804,9 +1816,15 @@ function PlayInner() { })(); setPendingClick(null); - void performSceneTransition(promise, exit, visited, decision.freeformAction); + void performSceneTransition( + promise, + exit, + visited, + decision.freeformAction, + () => onFreeformInput(text), + ); } catch (e) { - if (!handleAuthError(e)) setError(String(e)); + if (!handleAuthError(e, () => onFreeformInput(text))) setError(String(e)); setPhase("ready"); } } @@ -1908,10 +1926,11 @@ function PlayInner() { exit, visited, decision.intent.freeformAction, + () => onBackgroundClick(click), ); } } catch (e) { - if (!handleAuthError(e)) setError(String(e)); + if (!handleAuthError(e, () => onBackgroundClick(click))) setError(String(e)); setPendingClick(null); setPhase("ready"); } @@ -2158,13 +2177,14 @@ function PlayInner() { { setAuthModalOpen(false); - authResolveRef.current?.(); + // User dismissed login — drop the retry, don't re-run the action. authResolveRef.current = null; }} onSuccess={() => { setAuthModalOpen(false); - authResolveRef.current?.(); + const retry = authResolveRef.current; authResolveRef.current = null; + retry?.(); }} /> )} diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts index ce142b1..1721b59 100644 --- a/lib/supabase/server.ts +++ b/lib/supabase/server.ts @@ -10,8 +10,14 @@ export async function createClient() { cookies: { getAll: () => cookieStore.getAll(), setAll: (cookiesToSet) => { - for (const { name, value, options } of cookiesToSet) { - cookieStore.set(name, value, options); + try { + for (const { name, value, options } of cookiesToSet) { + cookieStore.set(name, value, options); + } + } catch { + // `setAll` can be invoked from a Server Component, where the cookie + // store is read-only and throws. Safe to ignore — the proxy + // middleware refreshes the session on the next request. } }, }, diff --git a/proxy.ts b/proxy.ts index 84c5b62..8b890c0 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,7 +1,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { createServerClient } from "@supabase/ssr"; -export function proxy(request: NextRequest) { +export async function proxy(request: NextRequest) { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; if (!supabaseUrl || !supabaseKey) return NextResponse.next(); @@ -22,7 +22,10 @@ export function proxy(request: NextRequest) { }, }); - supabase.auth.getUser(); + // Must await: getUser() triggers the token refresh, and the refreshed + // cookies are written to `response` via the setAll callback above. Returning + // before it resolves can drop the refreshed session cookie. + await supabase.auth.getUser(); return response; }