From 87a2f93edbd260d4531c906d62471dc901ac927e Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Sat, 13 Jun 2026 17:33:55 +0800 Subject: [PATCH 1/3] feat(auth): add Supabase auth with Google, GitHub, and email OTP login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce user registration/login gated behind optional NEXT_PUBLIC_SUPABASE_* env vars (leave blank to disable — app behaves exactly as before). Adds proxy.ts for automatic cookie session refresh, requireUser() API route guards on all 7 compute-consuming routes, AuthModal (Google/GitHub OAuth + 6-digit email OTP), UserChip header component, and login_success analytics event. Identity is fully decoupled from Session/engine — no type changes. Co-Authored-By: Claude Opus 4.6 --- .env.example | 9 ++ app/api/beat-audio/route.ts | 4 + app/api/classify-freeform/route.ts | 4 + app/api/insert-beat/route.ts | 4 + app/api/parse-style-image/route.ts | 4 + app/api/scene/route.ts | 4 + app/api/start/route.ts | 4 + app/api/vision/route.ts | 4 + app/auth/callback/route.ts | 17 ++ app/page.tsx | 34 +++- app/play/page.tsx | 53 +++++-- components/AuthModal.tsx | 240 +++++++++++++++++++++++++++++ components/UserChip.tsx | 106 +++++++++++++ lib/analytics.ts | 1 + lib/engineClient.ts | 8 + lib/supabase/client.ts | 12 ++ lib/supabase/config.ts | 3 + lib/supabase/guard.ts | 15 ++ lib/supabase/server.ts | 20 +++ package.json | 2 + pnpm-lock.yaml | 81 ++++++++++ proxy.ts | 28 ++++ 22 files changed, 646 insertions(+), 11 deletions(-) create mode 100644 app/auth/callback/route.ts create mode 100644 components/AuthModal.tsx create mode 100644 components/UserChip.tsx create mode 100644 lib/supabase/client.ts create mode 100644 lib/supabase/config.ts create mode 100644 lib/supabase/guard.ts create mode 100644 lib/supabase/server.ts create mode 100644 proxy.ts diff --git a/.env.example b/.env.example index 2141759..0655d9d 100644 --- a/.env.example +++ b/.env.example @@ -161,3 +161,12 @@ NEXT_PUBLIC_UMAMI_DOMAINS= # WARNING: rotating this secret invalidates every share file ever issued # (decryption will fail with "文件校验失败"). Only change when you're OK with that. GALLERY_SECRET= + +# ---- 8. Auth · Supabase (optional — leave blank to disable) ------- +# Sign up at https://supabase.com, create a project, copy the URL and +# publishable key (starts with sb_publishable_ or eyJ…). +# Both blank → login UI is completely absent, all API routes run unguarded, +# and the app behaves exactly as before this feature existed. +# NEXT_PUBLIC_ vars are inlined at BUILD time. +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY= diff --git a/app/api/beat-audio/route.ts b/app/api/beat-audio/route.ts index 7ef2484..e80726e 100644 --- a/app/api/beat-audio/route.ts +++ b/app/api/beat-audio/route.ts @@ -2,10 +2,14 @@ import { requestBeatAudio } from "@infiplot/engine"; import type { BeatAudioRequest } from "@infiplot/types"; import { NextResponse } from "next/server"; import { loadEngineConfig } from "@/lib/config"; +import { requireUser } from "@/lib/supabase/guard"; export const runtime = "nodejs"; export async function POST(req: Request) { + const auth = await requireUser(); + if (auth instanceof NextResponse) return auth; + let body: BeatAudioRequest; try { body = (await req.json()) as BeatAudioRequest; diff --git a/app/api/classify-freeform/route.ts b/app/api/classify-freeform/route.ts index d2c10a2..5ff9733 100644 --- a/app/api/classify-freeform/route.ts +++ b/app/api/classify-freeform/route.ts @@ -2,10 +2,14 @@ import { classifyFreeform } from "@infiplot/engine"; import type { FreeformClassifyRequest } from "@infiplot/types"; import { NextResponse } from "next/server"; import { loadEngineConfig } from "@/lib/config"; +import { requireUser } from "@/lib/supabase/guard"; export const runtime = "nodejs"; export async function POST(req: Request) { + const auth = await requireUser(); + if (auth instanceof NextResponse) return auth; + let body: FreeformClassifyRequest; try { body = (await req.json()) as FreeformClassifyRequest; diff --git a/app/api/insert-beat/route.ts b/app/api/insert-beat/route.ts index 820b514..4d4a72d 100644 --- a/app/api/insert-beat/route.ts +++ b/app/api/insert-beat/route.ts @@ -2,10 +2,14 @@ import { requestInsertBeat } from "@infiplot/engine"; import type { InsertBeatRequest } from "@infiplot/types"; import { NextResponse } from "next/server"; import { loadEngineConfig } from "@/lib/config"; +import { requireUser } from "@/lib/supabase/guard"; export const runtime = "nodejs"; export async function POST(req: Request) { + const auth = await requireUser(); + if (auth instanceof NextResponse) return auth; + let body: InsertBeatRequest; try { body = (await req.json()) as InsertBeatRequest; diff --git a/app/api/parse-style-image/route.ts b/app/api/parse-style-image/route.ts index 1fca6e1..7568ef3 100644 --- a/app/api/parse-style-image/route.ts +++ b/app/api/parse-style-image/route.ts @@ -5,6 +5,7 @@ import type { } from "@infiplot/types"; import { NextResponse } from "next/server"; import { loadEngineConfig } from "@/lib/config"; +import { requireUser } from "@/lib/supabase/guard"; export const runtime = "nodejs"; @@ -26,6 +27,9 @@ Do NOT describe the characters, objects, or scene contents. Output exactly one J {"stylePrompt": ""}`; export async function POST(req: Request) { + const auth = await requireUser(); + if (auth instanceof NextResponse) return auth; + let body: ParseStyleImageRequest; try { body = (await req.json()) as ParseStyleImageRequest; diff --git a/app/api/scene/route.ts b/app/api/scene/route.ts index ed25b0f..7523054 100644 --- a/app/api/scene/route.ts +++ b/app/api/scene/route.ts @@ -2,6 +2,7 @@ import { requestScene } from "@infiplot/engine"; import type { Character, SceneRequest } from "@infiplot/types"; import { NextResponse } from "next/server"; import { loadEngineConfig } from "@/lib/config"; +import { requireUser } from "@/lib/supabase/guard"; function stripKnownVoices( characters: Character[], @@ -15,6 +16,9 @@ function stripKnownVoices( export const runtime = "nodejs"; export async function POST(req: Request) { + const auth = await requireUser(); + if (auth instanceof NextResponse) return auth; + let body: SceneRequest; try { body = (await req.json()) as SceneRequest; diff --git a/app/api/start/route.ts b/app/api/start/route.ts index 3ce3169..980d3b9 100644 --- a/app/api/start/route.ts +++ b/app/api/start/route.ts @@ -2,6 +2,7 @@ import { startSession } from "@infiplot/engine"; import type { StartRequest } from "@infiplot/types"; import { NextResponse } from "next/server"; import { loadEngineConfig } from "@/lib/config"; +import { requireUser } from "@/lib/supabase/guard"; export const runtime = "nodejs"; @@ -11,6 +12,9 @@ export const runtime = "nodejs"; const MAX_STYLE_REF_BYTES = 3 * 1024 * 1024; export async function POST(req: Request) { + const auth = await requireUser(); + if (auth instanceof NextResponse) return auth; + let body: StartRequest; try { body = (await req.json()) as StartRequest; diff --git a/app/api/vision/route.ts b/app/api/vision/route.ts index 3bd2d59..4280239 100644 --- a/app/api/vision/route.ts +++ b/app/api/vision/route.ts @@ -2,6 +2,7 @@ import { visionDecide } from "@infiplot/engine"; import type { VisionRequest } from "@infiplot/types"; import { NextResponse } from "next/server"; import { loadEngineConfig } from "@/lib/config"; +import { requireUser } from "@/lib/supabase/guard"; export const runtime = "nodejs"; @@ -11,6 +12,9 @@ export const runtime = "nodejs"; const MAX_ANNOTATED_BYTES = 3 * 1024 * 1024; export async function POST(req: Request) { + const auth = await requireUser(); + if (auth instanceof NextResponse) return auth; + let body: VisionRequest; try { body = (await req.json()) as VisionRequest; diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..92e0bfd --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,17 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +export async function GET(request: NextRequest) { + const { searchParams, origin } = request.nextUrl; + const code = searchParams.get("code"); + const next = searchParams.get("next") ?? "/"; + + if (code) { + const supabase = await createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (!error) { + return NextResponse.redirect(`${origin}${next}`); + } + } + return NextResponse.redirect(`${origin}/?auth_error=1`); +} diff --git a/app/page.tsx b/app/page.tsx index 6d61a19..b73d4e0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -16,6 +16,10 @@ import { analyzeImageDataUrl } from "@infiplot/ai-client"; import { readStoredModelConfig, resolveEngineConfig } from "@/lib/clientModelConfig"; import { STYLE_EXTRACTION_PROMPT } from "@/lib/styleExtraction"; import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare"; +import { AUTH_ENABLED } from "@/lib/supabase/config"; +import { createClient as createSupabaseClient } from "@/lib/supabase/client"; +import { AuthModal } from "@/components/AuthModal"; +import { UserChip } from "@/components/UserChip"; /* ============================================================================ InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型) @@ -1281,6 +1285,8 @@ export default function HomePage() { const [ttsConfigured, setTtsConfigured] = useState(false); const [playerName, setPlayerName] = useState(""); const [visionClickEnabled, setVisionClickEnabled] = useState(true); + const [authModalOpen, setAuthModalOpen] = useState(false); + const [pendingAction, setPendingAction] = useState<"start" | null>(null); const styleRow = OPTS.findIndex((o) => o.modal); const voiceRow = OPTS.findIndex((o) => o.label === "语音配音"); @@ -1350,7 +1356,17 @@ export default function HomePage() { } }; - const start = () => { + const start = async () => { + if (AUTH_ENABLED) { + const sb = createSupabaseClient(); + const { data } = await sb.auth.getUser(); + if (!data.user) { + setPendingAction("start"); + setAuthModalOpen(true); + return; + } + } + // 空输入时落回 Typewriter 当前闪动的示例——用户看到啥就玩啥, // 不会再出现「点开始 → 剧情和占位文字毫无关系」的体验断层。 const userPrompt = @@ -1525,6 +1541,7 @@ export default function HomePage() { > + setAuthModalOpen(true)} /> @@ -1813,6 +1830,21 @@ export default function HomePage() { }} /> )} + {authModalOpen && ( + { + setAuthModalOpen(false); + setPendingAction(null); + }} + onSuccess={() => { + setAuthModalOpen(false); + if (pendingAction === "start") { + setPendingAction(null); + start(); + } + }} + /> + )} ); } diff --git a/app/play/page.tsx b/app/play/page.tsx index 64ec63f..076fb0d 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -35,6 +35,7 @@ import { visionDecide, classifyFreeform, requestInsertBeat, + AuthRequiredError, } from "@/lib/engineClient"; import type { Beat, @@ -50,6 +51,9 @@ import type { TtsConfig, } from "@infiplot/types"; import { track } from "@/lib/analytics"; +import { AUTH_ENABLED } from "@/lib/supabase/config"; +import { AuthModal } from "@/components/AuthModal"; +import { UserChip } from "@/components/UserChip"; const MUTED_STORAGE_KEY = "infiplot:muted"; @@ -536,12 +540,22 @@ function PlayInner() { // Consecutive server-side TTS misses (null audio / failed /api/beat-audio). const [settingsOpen, setSettingsOpen] = useState(false); const [visionClickEnabled, setVisionClickEnabled] = useState(true); + const [authModalOpen, setAuthModalOpen] = useState(false); + const authResolveRef = useRef<(() => void) | null>(null); // Top-of-screen progress toast for the gallery / story export pipeline. // null when idle; { done, total, label } while collecting beat audio. const [exportProgress, setExportProgress] = useState< { done: number; total: number; label: string } | null >(null); + const handleAuthError = useCallback((e: unknown): boolean => { + if (e instanceof AuthRequiredError) { + setAuthModalOpen(true); + return true; + } + return false; + }, []); + const startedRef = useRef(false); const poolRef = useRef>(new Map()); // Accumulator for resolved prefetches across the whole session — every @@ -1215,7 +1229,7 @@ function PlayInner() { setPhase("ready"); track("scene_reached", { scene_index: 1 }); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + if (!handleAuthError(e)) setError(e instanceof Error ? e.message : String(e)); } })(); return; @@ -1352,7 +1366,9 @@ function PlayInner() { setPhase("ready"); track("scene_reached", { scene_index: initial.history.length }); }) - .catch((e) => setError(String(e))); + .catch((e) => { + if (!handleAuthError(e)) setError(String(e)); + }); }, [params, router]); // ── Prefetch on scene entry: L1 + recursive L2/L3 for must-pass ────── @@ -1477,7 +1493,7 @@ function PlayInner() { setPhase("ready"); return; } - setError(String(e)); + if (!handleAuthError(e)) setError(String(e)); setPhase("ready"); } } @@ -1550,7 +1566,7 @@ function PlayInner() { setPhase("ready"); track("scene_reached", { scene_index: nextSession.history.length }); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + if (!handleAuthError(e)) setError(e instanceof Error ? e.message : String(e)); setPhase("ready"); } })(); @@ -1790,7 +1806,7 @@ function PlayInner() { setPendingClick(null); void performSceneTransition(promise, exit, visited, decision.freeformAction); } catch (e) { - setError(String(e)); + if (!handleAuthError(e)) setError(String(e)); setPhase("ready"); } } @@ -1895,7 +1911,7 @@ function PlayInner() { ); } } catch (e) { - setError(String(e)); + if (!handleAuthError(e)) setError(String(e)); setPendingClick(null); setPhase("ready"); } @@ -2027,10 +2043,13 @@ function PlayInner() { InfiPlot -
- 第 · {String(sceneCount).padStart(3, "0")} · 幕 - · - {String(beatCount).padStart(3, "0")} · 拍 +
+
+ 第 · {String(sceneCount).padStart(3, "0")} · 幕 + · + {String(beatCount).padStart(3, "0")} · 拍 +
+ setAuthModalOpen(true)} />
@@ -2135,6 +2154,20 @@ function PlayInner() { footerNote="保存后配音 Key 会立即生效,用你自己的额度合成当前这一幕的配音。" /> )} + {authModalOpen && ( + { + setAuthModalOpen(false); + authResolveRef.current?.(); + authResolveRef.current = null; + }} + onSuccess={() => { + setAuthModalOpen(false); + authResolveRef.current?.(); + authResolveRef.current = null; + }} + /> + )}
); } diff --git a/components/AuthModal.tsx b/components/AuthModal.tsx new file mode 100644 index 0000000..8f1cd9b --- /dev/null +++ b/components/AuthModal.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { createClient } from "@/lib/supabase/client"; +import { track } from "@/lib/analytics"; + +type AuthStep = "pick" | "email-input" | "otp-verify"; + +export function AuthModal({ + onClose, + onSuccess, +}: { + onClose: () => void; + onSuccess: () => void; +}) { + const [step, setStep] = useState("pick"); + const [email, setEmail] = useState(""); + const [otp, setOtp] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + const handleOAuth = useCallback( + async (provider: "google" | "github") => { + setLoading(true); + setError(""); + const supabase = createClient(); + const { error: oauthError } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(window.location.pathname + window.location.search)}`, + }, + }); + if (oauthError) { + setError(oauthError.message); + setLoading(false); + } + }, + [], + ); + + const handleSendOtp = useCallback(async () => { + const trimmed = email.trim(); + if (!trimmed) return; + setLoading(true); + setError(""); + const supabase = createClient(); + const { error: otpError } = await supabase.auth.signInWithOtp({ + email: trimmed, + }); + setLoading(false); + if (otpError) { + setError(otpError.message); + } else { + setStep("otp-verify"); + } + }, [email]); + + const handleVerifyOtp = useCallback(async () => { + const trimmedOtp = otp.trim(); + if (!trimmedOtp) return; + setLoading(true); + setError(""); + const supabase = createClient(); + const { error: verifyError } = await supabase.auth.verifyOtp({ + email: email.trim(), + token: trimmedOtp, + type: "email", + }); + setLoading(false); + if (verifyError) { + setError(verifyError.message); + } else { + track("login_success", { provider: "email" }); + onSuccess(); + } + }, [email, otp, onSuccess]); + + return ( +
+
e.stopPropagation()} + style={{ + background: "rgba(14, 10, 6, 0.92)", + border: "1.5px solid rgba(175, 138, 72, 0.72)", + borderRadius: "8px", + backdropFilter: "blur(14px)", + WebkitBackdropFilter: "blur(14px)", + boxShadow: + "0 10px 42px rgba(0,0,0,0.62), inset 0 1px 0 rgba(200,165,90,0.12)", + }} + role="dialog" + aria-modal="true" + aria-label="登录" + > + {/* header */} +
+
+ + {step === "pick" && "登录以继续"} + {step === "email-input" && "邮箱登录"} + {step === "otp-verify" && "验证码"} +
+ +
+ +
+ {error && ( +

{error}

+ )} + + {step === "pick" && ( + <> + + +
+
+ +
+
+ + + )} + + {step === "email-input" && ( + <> + setEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSendOtp()} + placeholder="your@email.com" + autoFocus + className="w-full rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-3.5 py-2.5 text-[13px] text-cream-50/90 placeholder:text-cream-50/30 outline-none focus:border-[rgba(175,138,72,0.6)]" + /> + + + + )} + + {step === "otp-verify" && ( + <> +

+ 验证码已发送至 {email.trim()} +

+ setOtp(e.target.value.replace(/\D/g, ""))} + onKeyDown={(e) => e.key === "Enter" && handleVerifyOtp()} + placeholder="6 位验证码" + autoFocus + className="w-full rounded-md border border-cream-50/15 bg-cream-50/[0.06] px-3.5 py-2.5 text-center text-[16px] tracking-[0.35em] text-cream-50/90 placeholder:text-cream-50/30 placeholder:tracking-normal outline-none focus:border-[rgba(175,138,72,0.6)]" + /> + + + + )} +
+
+
+ ); +} diff --git a/components/UserChip.tsx b/components/UserChip.tsx new file mode 100644 index 0000000..2f47fdd --- /dev/null +++ b/components/UserChip.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { AUTH_ENABLED } from "@/lib/supabase/config"; +import { createClient } from "@/lib/supabase/client"; +import type { AuthChangeEvent, Session, User } from "@supabase/supabase-js"; + +export function UserChip({ + onLoginClick, +}: { + onLoginClick: () => void; +}) { + const [user, setUser] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + + useEffect(() => { + if (!AUTH_ENABLED) return; + const supabase = createClient(); + supabase.auth.getUser().then(({ data }: { data: { user: User | null } }) => setUser(data.user)); + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event: AuthChangeEvent, session: Session | null) => { + setUser(session?.user ?? null); + }); + return () => subscription.unsubscribe(); + }, []); + + const handleLogout = useCallback(async () => { + const supabase = createClient(); + await supabase.auth.signOut(); + setUser(null); + setMenuOpen(false); + }, []); + + if (!AUTH_ENABLED) return null; + + if (!user) { + return ( + + ); + } + + const label = + user.user_metadata?.full_name ?? + user.email?.split("@")[0] ?? + "User"; + const avatarUrl = user.user_metadata?.avatar_url as string | undefined; + const initial = label.charAt(0).toUpperCase(); + + return ( +
+ + {menuOpen && ( + <> +
setMenuOpen(false)} + /> +
+ +
+ + )} +
+ ); +} diff --git a/lib/analytics.ts b/lib/analytics.ts index fb89fb3..32785f1 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -54,6 +54,7 @@ type AnalyticsEventData = { fullscreen_toggle: { on: boolean }; play_heartbeat: never; gallery_export: { scene_count: number; audio_count: number }; + login_success: { provider: "google" | "github" | "email" }; }; export type AnalyticsEvent = keyof AnalyticsEventData; diff --git a/lib/engineClient.ts b/lib/engineClient.ts index 066e342..a6163aa 100644 --- a/lib/engineClient.ts +++ b/lib/engineClient.ts @@ -31,6 +31,13 @@ function getClientConfig(): EngineConfig | null { return resolveEngineConfig(modelCfg, ttsCfg); } +export class AuthRequiredError extends Error { + constructor() { + super("Unauthorized"); + this.name = "AuthRequiredError"; + } +} + async function postJson(path: string, body: unknown): Promise { const res = await fetch(path, { method: "POST", @@ -38,6 +45,7 @@ async function postJson(path: string, body: unknown): Promise { body: JSON.stringify(body), }); if (!res.ok) { + if (res.status === 401) throw new AuthRequiredError(); let message = `HTTP ${res.status}`; try { const data = (await res.json()) as { error?: string }; diff --git a/lib/supabase/client.ts b/lib/supabase/client.ts new file mode 100644 index 0000000..1078a77 --- /dev/null +++ b/lib/supabase/client.ts @@ -0,0 +1,12 @@ +import { createBrowserClient } from "@supabase/ssr"; + +let client: ReturnType | null = null; + +export function createClient() { + if (client) return client; + client = createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, + ); + return client; +} diff --git a/lib/supabase/config.ts b/lib/supabase/config.ts new file mode 100644 index 0000000..7bb2167 --- /dev/null +++ b/lib/supabase/config.ts @@ -0,0 +1,3 @@ +export const AUTH_ENABLED = + !!process.env.NEXT_PUBLIC_SUPABASE_URL && + !!process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; diff --git a/lib/supabase/guard.ts b/lib/supabase/guard.ts new file mode 100644 index 0000000..90a98c5 --- /dev/null +++ b/lib/supabase/guard.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { AUTH_ENABLED } from "./config"; +import { createClient } from "./server"; + +export async function requireUser(): Promise< + { userId: string } | NextResponse +> { + if (!AUTH_ENABLED) return { userId: "anonymous" }; + const supabase = await createClient(); + const claims = await supabase.auth.getClaims(); + if (claims.error || !claims.data?.claims?.sub) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + return { userId: claims.data.claims.sub }; +} diff --git a/lib/supabase/server.ts b/lib/supabase/server.ts new file mode 100644 index 0000000..ce142b1 --- /dev/null +++ b/lib/supabase/server.ts @@ -0,0 +1,20 @@ +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; + +export async function createClient() { + const cookieStore = await cookies(); + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, + { + cookies: { + getAll: () => cookieStore.getAll(), + setAll: (cookiesToSet) => { + for (const { name, value, options } of cookiesToSet) { + cookieStore.set(name, value, options); + } + }, + }, + }, + ); +} diff --git a/package.json b/package.json index 94d7212..91ffb17 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "deploy:cf": "opennextjs-cloudflare deploy" }, "dependencies": { + "@supabase/ssr": "^0.12", + "@supabase/supabase-js": "^2.108", "jsonrepair": "^3.14.0", "jszip": "^3.10.1", "next": "^16.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb56ee5..8d0e374 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@supabase/ssr': + specifier: ^0.12 + version: 0.12.0(@supabase/supabase-js@2.108.1) + '@supabase/supabase-js': + specifier: ^2.108 + version: 2.108.1 jsonrepair: specifier: ^3.14.0 version: 3.14.0 @@ -1214,6 +1220,38 @@ packages: '@speed-highlight/core@1.2.15': resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + '@supabase/auth-js@2.108.1': + resolution: {integrity: sha512-Lle5rKU8f9LF3K5dDd8Or8mkkG+ptzRZZWKPVMm9B9UuovH65Ss2+iFnQqRsCqaGouvJEcTWyl0cj2riNrrDLQ==} + engines: {node: '>=20.0.0'} + + '@supabase/functions-js@2.108.1': + resolution: {integrity: sha512-fxBRW/A4IG7ADQztVt0NaEy5ysiO1WJ2pbldsnBchrkHuyepX0Krek9qA9T4gUQBVVTCE9Ea4pdsM5hfn3nc4A==} + engines: {node: '>=20.0.0'} + + '@supabase/phoenix@0.4.2': + resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==} + + '@supabase/postgrest-js@2.108.1': + resolution: {integrity: sha512-9lj2MCPPMgSTaJ5y+amnhb3TWPtMFVlbDn2hmX/VV91xQU4j0AauwfMaBErHBJ+zzsSwjc0jLU+zLIZFLQzfig==} + engines: {node: '>=20.0.0'} + + '@supabase/realtime-js@2.108.1': + resolution: {integrity: sha512-mHGGqOjwd1XTydcoffUqEMsbFQHUi6A3uhQ0EXr3iqzpLqItxKA9nbN6gIQxrZ7JRRnuUe/iOFPUkYV9Tdc5lg==} + engines: {node: '>=20.0.0'} + + '@supabase/ssr@0.12.0': + resolution: {integrity: sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==} + peerDependencies: + '@supabase/supabase-js': ^2.108.0 + + '@supabase/storage-js@2.108.1': + resolution: {integrity: sha512-Er0SGGt85iT6ye+SSh98Az6L2CesoZJuyzEZYH2oBOAnIxa9Nn4CtwUC3veGxYggoT56X+3tVuuQeDBP8kR8sg==} + engines: {node: '>=20.0.0'} + + '@supabase/supabase-js@2.108.1': + resolution: {integrity: sha512-V/1hRKLSCJ0zEL+9QFRBUtivvePfOsaAYQmC0HhFNSHC2F3xFs4jSF3YhkLmzex6E4V4FGvmBDOP72D/53NnZA==} + engines: {node: '>=20.0.0'} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1707,6 +1745,10 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -3757,6 +3799,43 @@ snapshots: '@speed-highlight/core@1.2.15': {} + '@supabase/auth-js@2.108.1': + dependencies: + tslib: 2.8.1 + + '@supabase/functions-js@2.108.1': + dependencies: + tslib: 2.8.1 + + '@supabase/phoenix@0.4.2': {} + + '@supabase/postgrest-js@2.108.1': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.108.1': + dependencies: + '@supabase/phoenix': 0.4.2 + tslib: 2.8.1 + + '@supabase/ssr@0.12.0(@supabase/supabase-js@2.108.1)': + dependencies: + '@supabase/supabase-js': 2.108.1 + cookie: 1.1.1 + + '@supabase/storage-js@2.108.1': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.108.1': + dependencies: + '@supabase/auth-js': 2.108.1 + '@supabase/functions-js': 2.108.1 + '@supabase/postgrest-js': 2.108.1 + '@supabase/realtime-js': 2.108.1 + '@supabase/storage-js': 2.108.1 + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -4313,6 +4392,8 @@ snapshots: dependencies: ms: 2.1.3 + iceberg-js@0.8.1: {} + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..84c5b62 --- /dev/null +++ b/proxy.ts @@ -0,0 +1,28 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { createServerClient } from "@supabase/ssr"; + +export 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(); + + let response = NextResponse.next({ request }); + const supabase = createServerClient(supabaseUrl, supabaseKey, { + cookies: { + getAll: () => request.cookies.getAll(), + setAll: (cookiesToSet) => { + for (const { name, value } of cookiesToSet) { + request.cookies.set(name, value); + } + response = NextResponse.next({ request }); + for (const { name, value, options } of cookiesToSet) { + response.cookies.set(name, value, options); + } + }, + }, + }); + + supabase.auth.getUser(); + + return response; +} From 89a5c540653db7bca4ef381adf678b9aeb191dd3 Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Sat, 13 Jun 2026 19:27:51 +0800 Subject: [PATCH 2/3] 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; } From 11f5ca83ec384cd5d82d5ebf56c6a08d7a147bc5 Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Sat, 13 Jun 2026 23:19:44 +0800 Subject: [PATCH 3/3] fix(auth): reject control chars in OAuth callback next param Defense-in-depth against header injection if the post-login redirect target ever reaches a context that doesn't re-encode it. Co-Authored-By: Claude Fable 5 --- app/auth/callback/route.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index a365673..d9e1ab6 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -8,6 +8,12 @@ import { createClient } from "@/lib/supabase/server"; function safeNext(raw: string | null): string { if (!raw || !raw.startsWith("/")) return "/"; if (raw.startsWith("//") || raw.startsWith("/\\")) return "/"; + // Reject control chars (CR/LF etc.) — defense-in-depth against header + // injection if `next` ever reaches a context that doesn't re-encode it. + for (let i = 0; i < raw.length; i++) { + const code = raw.charCodeAt(i); + if (code < 0x20 || code === 0x7f) return "/"; + } return raw; }