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:
@@ -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();
|
||||
|
||||
+171
-21
@@ -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<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({
|
||||
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();
|
||||
}
|
||||
}}
|
||||
|
||||
+30
-10
@@ -548,13 +548,20 @@ function PlayInner() {
|
||||
{ done: number; total: number; label: string } | null
|
||||
>(null);
|
||||
|
||||
const handleAuthError = useCallback((e: unknown): boolean => {
|
||||
// `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<Map<string, PrefetchEntry>>(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() {
|
||||
<AuthModal
|
||||
onClose={() => {
|
||||
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?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -10,9 +10,15 @@ export async function createClient() {
|
||||
cookies: {
|
||||
getAll: () => cookieStore.getAll(),
|
||||
setAll: (cookiesToSet) => {
|
||||
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.
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user