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 <noreply@anthropic.com>
This commit is contained in:
yuanzonghao
2026-06-13 19:27:51 +08:00
parent 87a2f93edb
commit 89a5c54065
5 changed files with 237 additions and 41 deletions
+18 -1
View File
@@ -1,10 +1,27 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { AUTH_ENABLED } from "@/lib/supabase/config";
import { createClient } from "@/lib/supabase/server"; 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) { export async function GET(request: NextRequest) {
const { searchParams, origin } = request.nextUrl; 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 code = searchParams.get("code");
const next = searchParams.get("next") ?? "/"; const next = safeNext(searchParams.get("next"));
if (code) { if (code) {
const supabase = await createClient(); const supabase = await createClient();
+171 -21
View File
@@ -851,6 +851,51 @@ function CategorySelect({
/* ---------- style picker modal ---------- */ /* ---------- 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<boolean> {
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<string> {
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({ function StyleModal({
items, items,
value, value,
@@ -860,6 +905,7 @@ function StyleModal({
setCustomStyleGuide, setCustomStyleGuide,
customStyleRefImage, customStyleRefImage,
setCustomStyleRefImage, setCustomStyleRefImage,
onRequireAuth,
}: { }: {
items: string[]; items: string[];
value: number; value: number;
@@ -869,6 +915,7 @@ function StyleModal({
setCustomStyleGuide: (s: string) => void; setCustomStyleGuide: (s: string) => void;
customStyleRefImage: string; customStyleRefImage: string;
setCustomStyleRefImage: (s: string) => void; setCustomStyleRefImage: (s: string) => void;
onRequireAuth: () => void;
}) { }) {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [shown, setShown] = useState(false); const [shown, setShown] = useState(false);
@@ -983,31 +1030,18 @@ function StyleModal({
setParsing(true); setParsing(true);
try { try {
const resized = await resizeImageToDataUrl(file); const resized = await resizeImageToDataUrl(file);
const modelCfg = readStoredModelConfig(); // The parse is a paid vision call, so require login first. The resize is
let stylePrompt: string; // already done — stash it so login can auto-resume the parse on return.
if (modelCfg) { if (!(await isAuthed())) {
const config = resolveEngineConfig(modelCfg, null);
const raw = await analyzeImageDataUrl(config.vision, resized, STYLE_EXTRACTION_PROMPT);
let parsed: { stylePrompt?: string };
try { try {
parsed = JSON.parse(raw); sessionStorage.setItem(PENDING_PARSE_KEY, resized);
} catch { } catch {
parsed = { stylePrompt: raw }; /* too big to stash — user re-uploads after login */
} }
stylePrompt = (parsed.stylePrompt ?? "").trim(); onRequireAuth();
} else { return;
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();
} }
const stylePrompt = await extractStylePromptFromImage(resized);
if (!stylePrompt) throw new Error("视觉模型返回了空的风格描述"); if (!stylePrompt) throw new Error("视觉模型返回了空的风格描述");
setDraft(stylePrompt); setDraft(stylePrompt);
setCustomStyleRefImage(resized); 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 () => { const start = async () => {
if (AUTH_ENABLED) { if (AUTH_ENABLED) {
const sb = createSupabaseClient(); const sb = createSupabaseClient();
const { data } = await sb.auth.getUser(); const { data } = await sb.auth.getUser();
if (!data.user) { if (!data.user) {
persistPendingStart();
setPendingAction("start"); setPendingAction("start");
setAuthModalOpen(true); setAuthModalOpen(true);
return; return;
@@ -1811,6 +1946,7 @@ export default function HomePage() {
setCustomStyleGuide={setCustomStyleGuide} setCustomStyleGuide={setCustomStyleGuide}
customStyleRefImage={customStyleRefImage} customStyleRefImage={customStyleRefImage}
setCustomStyleRefImage={setCustomStyleRefImage} setCustomStyleRefImage={setCustomStyleRefImage}
onRequireAuth={() => setAuthModalOpen(true)}
/> />
)} )}
{settingsOpen && ( {settingsOpen && (
@@ -1835,11 +1971,25 @@ export default function HomePage() {
onClose={() => { onClose={() => {
setAuthModalOpen(false); setAuthModalOpen(false);
setPendingAction(null); setPendingAction(null);
try {
sessionStorage.removeItem(PENDING_START_KEY);
sessionStorage.removeItem(PENDING_PARSE_KEY);
} catch {
/* ignore */
}
}} }}
onSuccess={() => { onSuccess={() => {
setAuthModalOpen(false); 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") { if (pendingAction === "start") {
setPendingAction(null); setPendingAction(null);
try {
sessionStorage.removeItem(PENDING_START_KEY);
} catch {
/* ignore */
}
start(); start();
} }
}} }}
+35 -15
View File
@@ -548,13 +548,20 @@ function PlayInner() {
{ done: number; total: number; label: string } | null { done: number; total: number; label: string } | null
>(null); >(null);
const handleAuthError = useCallback((e: unknown): boolean => { // `retry` re-runs the action that hit the 401, replayed by AuthModal.onSuccess
if (e instanceof AuthRequiredError) { // after the user signs in. Omitted by callers whose path can't actually 401
setAuthModalOpen(true); // (initial load already gated on the homepage, recorded replay is local).
return true; const handleAuthError = useCallback(
} (e: unknown, retry?: () => void): boolean => {
return false; if (e instanceof AuthRequiredError) {
}, []); authResolveRef.current = retry ?? null;
setAuthModalOpen(true);
return true;
}
return false;
},
[],
);
const startedRef = useRef(false); const startedRef = useRef(false);
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map()); const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
@@ -1436,6 +1443,7 @@ function PlayInner() {
exit: SceneExit, exit: SceneExit,
visitedForCurrent: string[], visitedForCurrent: string[],
exitLabel: string, exitLabel: string,
retry?: () => void,
) { ) {
setPhase("transitioning"); setPhase("transitioning");
setPendingClick(null); setPendingClick(null);
@@ -1493,7 +1501,7 @@ function PlayInner() {
setPhase("ready"); setPhase("ready");
return; return;
} }
if (!handleAuthError(e)) setError(String(e)); if (!handleAuthError(e, retry)) setError(String(e));
setPhase("ready"); setPhase("ready");
} }
} }
@@ -1681,7 +1689,9 @@ function PlayInner() {
const cached = consumeChoice(poolRef.current, choice.id); const cached = consumeChoice(poolRef.current, choice.id);
if (cached) { if (cached) {
void performSceneTransition(cached, exit, visited, choice.label); void performSceneTransition(cached, exit, visited, choice.label, () =>
onSelectChoice(choice),
);
return; return;
} }
@@ -1706,7 +1716,9 @@ function PlayInner() {
return data; return data;
})(); })();
void performSceneTransition(promise, exit, visited, choice.label); void performSceneTransition(promise, exit, visited, choice.label, () =>
onSelectChoice(choice),
);
} }
async function onFreeformInput(text: string) { async function onFreeformInput(text: string) {
@@ -1804,9 +1816,15 @@ function PlayInner() {
})(); })();
setPendingClick(null); setPendingClick(null);
void performSceneTransition(promise, exit, visited, decision.freeformAction); void performSceneTransition(
promise,
exit,
visited,
decision.freeformAction,
() => onFreeformInput(text),
);
} catch (e) { } catch (e) {
if (!handleAuthError(e)) setError(String(e)); if (!handleAuthError(e, () => onFreeformInput(text))) setError(String(e));
setPhase("ready"); setPhase("ready");
} }
} }
@@ -1908,10 +1926,11 @@ function PlayInner() {
exit, exit,
visited, visited,
decision.intent.freeformAction, decision.intent.freeformAction,
() => onBackgroundClick(click),
); );
} }
} catch (e) { } catch (e) {
if (!handleAuthError(e)) setError(String(e)); if (!handleAuthError(e, () => onBackgroundClick(click))) setError(String(e));
setPendingClick(null); setPendingClick(null);
setPhase("ready"); setPhase("ready");
} }
@@ -2158,13 +2177,14 @@ function PlayInner() {
<AuthModal <AuthModal
onClose={() => { onClose={() => {
setAuthModalOpen(false); setAuthModalOpen(false);
authResolveRef.current?.(); // User dismissed login — drop the retry, don't re-run the action.
authResolveRef.current = null; authResolveRef.current = null;
}} }}
onSuccess={() => { onSuccess={() => {
setAuthModalOpen(false); setAuthModalOpen(false);
authResolveRef.current?.(); const retry = authResolveRef.current;
authResolveRef.current = null; authResolveRef.current = null;
retry?.();
}} }}
/> />
)} )}
+8 -2
View File
@@ -10,8 +10,14 @@ export async function createClient() {
cookies: { cookies: {
getAll: () => cookieStore.getAll(), getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => { setAll: (cookiesToSet) => {
for (const { name, value, options } of cookiesToSet) { try {
cookieStore.set(name, value, options); 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.
} }
}, },
}, },
+5 -2
View File
@@ -1,7 +1,7 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { createServerClient } from "@supabase/ssr"; 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 supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
if (!supabaseUrl || !supabaseKey) return NextResponse.next(); 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; return response;
} }