From b6f911446fcb6da73181c317e11032bbf814dd98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A1=BE=E9=9B=A8=E6=99=A8?= Date: Sat, 23 May 2026 03:23:53 +0800 Subject: [PATCH 1/3] fix: change session cookie SameSite from Strict to Lax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SameSite=Strict prevents session cookies from being sent on cross-site top-level navigations (e.g. clicking a link from another site). This caused resolveFrontendTheme to return different themes on consecutive requests within a redirect chain, creating infinite 302 loops: 1. External link to /dashboard → no session cookie (Strict blocks it) → GetTheme() returns system default (e.g. 'classic') → MapFrontendPath redirects /dashboard → /console (302) 2. 302 redirect to /console → session cookie still not sent (Strict blocks cookies for the entire cross-site navigation chain) → But if session somehow resolves to user's DB theme ('default') → MapFrontendPath redirects /console → /dashboard (302) → Infinite loop SameSite=Lax allows cookies on cross-site top-level GET navigations while still blocking them on cross-site POST requests, iframe loads, and AJAX calls — providing strong CSRF protection without breaking normal link-following. --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index bc0d038bea2..9453d34443c 100644 --- a/main.go +++ b/main.go @@ -181,7 +181,7 @@ func main() { MaxAge: 2592000, // 30 days HttpOnly: true, Secure: false, - SameSite: http.SameSiteStrictMode, + SameSite: http.SameSiteLaxMode, }) server.Use(sessions.Sessions("session", store)) From 4053819a6d82dca197fdedbbad7eb6bdfe391770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A1=BE=E9=9B=A8=E6=99=A8?= Date: Sat, 23 May 2026 03:48:10 +0800 Subject: [PATCH 2/3] refactor: use localStorage for frontend theme, remove cookie and backend sync - Replace cookie-based theme storage with localStorage (EnabledClassicFrontend key) - Remove backend API calls for theme preference (/api/user/self frontend_theme) - Remove server-side theme resolution from cookies in web-router.go - Update classic theme UserContext and PreferencesSettings to use localStorage - Update default frontend theme logic to use localStorage only - Set session cookie SameSite=Lax and Secure conditional on GIN_MODE - Simplify resolveFrontendTheme to only use common.GetTheme() --- main.go | 4 +- router/web-router.go | 38 ----------- .../personal/cards/PreferencesSettings.jsx | 66 +++---------------- web/classic/src/context/User/index.jsx | 29 +------- .../features/auth/hooks/use-auth-redirect.ts | 57 ++-------------- .../components/frontend-theme-card.tsx | 26 -------- web/default/src/lib/frontend-theme.ts | 14 ++-- .../src/routes/_authenticated/route.tsx | 29 ++------ 8 files changed, 34 insertions(+), 229 deletions(-) diff --git a/main.go b/main.go index 9453d34443c..e77a54cec4f 100644 --- a/main.go +++ b/main.go @@ -178,9 +178,9 @@ func main() { store := cookie.NewStore([]byte(common.SessionSecret)) store.Options(sessions.Options{ Path: "/", - MaxAge: 2592000, // 30 days + MaxAge: 2592000, HttpOnly: true, - Secure: false, + Secure: os.Getenv("GIN_MODE") == "release", SameSite: http.SameSiteLaxMode, }) server.Use(sessions.Sessions("session", store)) diff --git a/router/web-router.go b/router/web-router.go index 804a357d694..9271521fb2c 100644 --- a/router/web-router.go +++ b/router/web-router.go @@ -8,9 +8,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/controller" "github.com/QuantumNous/new-api/middleware" - "github.com/QuantumNous/new-api/model" "github.com/gin-contrib/gzip" - "github.com/gin-contrib/sessions" "github.com/gin-contrib/static" "github.com/gin-gonic/gin" ) @@ -55,42 +53,6 @@ func SetWebRouter(router *gin.Engine, assets ThemeAssets) { } func resolveFrontendTheme(c *gin.Context) string { - themeCookie, err := c.Cookie(common.FrontendThemeCookieName) - if err == nil { - theme := common.NormalizeFrontendTheme(themeCookie) - if theme != "" { - return theme - } - } - - session := sessions.Default(c) - sessionTheme := common.NormalizeFrontendTheme(common.Interface2String(session.Get(common.FrontendThemeSessionKey))) - if sessionTheme != "" { - common.SetFrontendThemeCookie(c, sessionTheme) - return sessionTheme - } - - if sessionID := session.Get("id"); sessionID != nil { - if userID, ok := sessionID.(int); ok && userID > 0 { - setting, err := model.GetUserSetting(userID, false) - if err == nil { - theme := common.NormalizeFrontendTheme(setting.FrontendTheme) - if theme != "" { - session.Set(common.FrontendThemeSessionKey, theme) - _ = session.Save() - common.SetFrontendThemeCookie(c, theme) - return theme - } - } - - fallbackTheme := common.NormalizeFrontendTheme(common.GetTheme()) - if fallbackTheme != "" { - session.Set(common.FrontendThemeSessionKey, fallbackTheme) - _ = session.Save() - return fallbackTheme - } - } - } return common.GetTheme() } diff --git a/web/classic/src/components/settings/personal/cards/PreferencesSettings.jsx b/web/classic/src/components/settings/personal/cards/PreferencesSettings.jsx index 2a8bec2c155..5db0be1542f 100644 --- a/web/classic/src/components/settings/personal/cards/PreferencesSettings.jsx +++ b/web/classic/src/components/settings/personal/cards/PreferencesSettings.jsx @@ -35,8 +35,7 @@ const languageOptions = [ { value: 'vi', label: 'Tiếng Việt' }, ]; -const FRONTEND_THEME_COOKIE_NAME = 'frontend_theme'; -const FRONTEND_THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; +const ENABLED_CLASSIC_FRONTEND_KEY = 'EnabledClassicFrontend'; const frontendThemeOptions = [ { value: 'default', label: '新版本 UI' }, @@ -44,44 +43,23 @@ const frontendThemeOptions = [ ]; const getFrontendTheme = () => { - if (typeof document === 'undefined') return 'default'; - const value = `; ${document.cookie}`; - const parts = value.split(`; ${FRONTEND_THEME_COOKIE_NAME}=`); - if (parts.length !== 2) return 'default'; - const theme = parts.pop()?.split(';').shift(); - return theme === 'classic' ? 'classic' : 'default'; + if (typeof localStorage === 'undefined') return 'default'; + return localStorage.getItem(ENABLED_CLASSIC_FRONTEND_KEY) === 'true' ? 'classic' : 'default'; }; const setFrontendTheme = (theme) => { - if (typeof document === 'undefined') return; - document.cookie = `${FRONTEND_THEME_COOKIE_NAME}=${theme}; path=/; max-age=${FRONTEND_THEME_COOKIE_MAX_AGE}`; + if (typeof localStorage === 'undefined') return; + if (theme === 'classic') { + localStorage.setItem(ENABLED_CLASSIC_FRONTEND_KEY, 'true'); + } else { + localStorage.removeItem(ENABLED_CLASSIC_FRONTEND_KEY); + } }; const getFrontendThemeSettingsPath = (theme) => { return theme === 'classic' ? '/console/personal' : '/profile'; }; -const updateFrontendThemePreference = async (theme, userId) => { - const response = await fetch('/api/user/self', { - method: 'PUT', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - 'New-API-User': String(userId ?? -1), - }, - body: JSON.stringify({ - frontend_theme: theme, - }), - }); - - const data = await response.json(); - if (!response.ok || !data?.success) { - throw new Error(data?.message || 'save frontend theme failed'); - } - return data; -}; - const PreferencesSettings = ({ t }) => { const { i18n } = useTranslation(); const [userState, userDispatch, startThemeNavigation] = useContext(UserContext); @@ -105,11 +83,6 @@ const PreferencesSettings = ({ t }) => { i18n.changeLanguage(lang); } } - if (settings.frontend_theme) { - setCurrentFrontendTheme( - settings.frontend_theme === 'classic' ? 'classic' : 'default', - ); - } } catch (e) {} }, [userState?.user?.setting, i18n]); @@ -174,27 +147,6 @@ const PreferencesSettings = ({ t }) => { const previousTheme = currentFrontendTheme; try { - await updateFrontendThemePreference(theme, userState?.user?.id); - - let settings = {}; - if (userState?.user?.setting) { - try { - settings = JSON.parse(userState.user.setting) || {}; - } catch (e) { - settings = {}; - } - } - settings.frontend_theme = theme; - const nextUser = { - ...userState.user, - setting: JSON.stringify(settings), - }; - userDispatch({ - type: 'login', - payload: nextUser, - }); - localStorage.setItem('user', JSON.stringify(nextUser)); - setFrontendTheme(theme); setCurrentFrontendTheme(theme); showSuccess(t('界面风格已切换,正在跳转')); diff --git a/web/classic/src/context/User/index.jsx b/web/classic/src/context/User/index.jsx index 47c056c87cc..bb393bcd46e 100644 --- a/web/classic/src/context/User/index.jsx +++ b/web/classic/src/context/User/index.jsx @@ -17,23 +17,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useEffect, useRef, useCallback } from 'react'; +import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { reducer, initialState } from './reducer'; import { normalizeLanguage } from '../../i18n/language'; -const FRONTEND_THEME_COOKIE_NAME = 'frontend_theme'; -const FRONTEND_THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; - -const normalizeFrontendTheme = (value) => { - return value === 'classic' ? 'classic' : 'default'; -}; - -const setFrontendTheme = (theme) => { - if (typeof document === 'undefined') return; - document.cookie = `${FRONTEND_THEME_COOKIE_NAME}=${theme}; path=/; max-age=${FRONTEND_THEME_COOKIE_MAX_AGE}`; -}; - export const UserContext = React.createContext({ state: initialState, dispatch: () => null, @@ -43,12 +31,8 @@ export const UserContext = React.createContext({ export const UserProvider = ({ children }) => { const [state, dispatch] = React.useReducer(reducer, initialState); const { i18n } = useTranslation(); - const themeRedirectAttemptedRef = useRef(false); - const themeNavigationPendingRef = useRef(false); - const startThemeNavigation = useCallback(() => { - themeNavigationPendingRef.current = true; - }, []); + const startThemeNavigation = React.useCallback(() => {}, []); useEffect(() => { if (state.user?.setting) { @@ -61,16 +45,7 @@ export const UserProvider = ({ children }) => { if (normalizedLanguage) { localStorage.setItem('i18nextLng', normalizedLanguage); } - if (settings.frontend_theme) { - const normalizedTheme = normalizeFrontendTheme(settings.frontend_theme); - setFrontendTheme(normalizedTheme); - if (normalizedTheme === 'default' && !themeRedirectAttemptedRef.current && !themeNavigationPendingRef.current) { - themeRedirectAttemptedRef.current = true; - window.location.replace('/dashboard'); - } - } } catch (e) { - // Ignore parse errors } } }, [state.user?.setting, i18n]); diff --git a/web/default/src/features/auth/hooks/use-auth-redirect.ts b/web/default/src/features/auth/hooks/use-auth-redirect.ts index 843d424fef5..d156457b74d 100644 --- a/web/default/src/features/auth/hooks/use-auth-redirect.ts +++ b/web/default/src/features/auth/hooks/use-auth-redirect.ts @@ -3,7 +3,7 @@ import i18n from 'i18next' import { useAuthStore } from '@/stores/auth-store' import { getSelf } from '@/lib/api' import { - normalizeFrontendTheme, + getFrontendTheme, setFrontendTheme, } from '@/lib/frontend-theme' import type { User } from '@/features/users/types' @@ -27,101 +27,56 @@ function getSavedLanguage(user: User): string | undefined { } } -function getSavedFrontendTheme(user: User): 'default' | 'classic' | undefined { - const userData = user as Record - if (typeof userData.frontend_theme === 'string') { - return normalizeFrontendTheme(userData.frontend_theme) - } - - if (typeof userData.setting !== 'string') { - return undefined - } - - try { - const setting = JSON.parse(userData.setting) as { frontend_theme?: unknown } - return typeof setting.frontend_theme === 'string' - ? normalizeFrontendTheme(setting.frontend_theme) - : undefined - } catch { - return undefined - } -} - -/** - * Hook for handling authentication redirects and user data management - */ export function useAuthRedirect() { const navigate = useNavigate() const { auth } = useAuthStore() - /** - * Handle successful login - * @param userData - Optional user data from login response - * @param redirectTo - Redirect path after login - */ const handleLoginSuccess = async ( userData?: { id?: number } | null, redirectTo?: string ) => { - // Save user ID if available if (userData?.id) { saveUserId(userData.id) } - // Fetch and set user data try { const self = await getSelf() if (self?.success && self.data) { const user = self.data as User auth.setUser(user) - // Update user ID if not already set if (user.id) { saveUserId(user.id) } - // Restore saved language preference const savedLang = getSavedLanguage(user) if (savedLang && savedLang !== i18n.language) { i18n.changeLanguage(savedLang) } - const savedTheme = getSavedFrontendTheme(user) - if (savedTheme) { - setFrontendTheme(savedTheme) - if (savedTheme === 'classic') { - window.location.replace('/console') - return - } + const theme = getFrontendTheme() + if (theme === 'classic') { + setFrontendTheme('classic') + window.location.replace('/console') + return } } } catch (error) { - // eslint-disable-next-line no-console console.error('Failed to fetch user data:', error) } - // Navigate to target page const targetPath = redirectTo || '/dashboard' navigate({ to: targetPath, replace: true }) } - /** - * Redirect to 2FA page - */ const redirectTo2FA = () => { navigate({ to: '/otp', replace: true }) } - /** - * Redirect to login page - */ const redirectToLogin = () => { navigate({ to: '/sign-in', replace: true }) } - /** - * Redirect to register page - */ const redirectToRegister = () => { navigate({ to: '/sign-up', replace: true }) } diff --git a/web/default/src/features/profile/components/frontend-theme-card.tsx b/web/default/src/features/profile/components/frontend-theme-card.tsx index 19f386d062e..730641bc6aa 100644 --- a/web/default/src/features/profile/components/frontend-theme-card.tsx +++ b/web/default/src/features/profile/components/frontend-theme-card.tsx @@ -2,7 +2,6 @@ import { useState } from 'react' import { MonitorSmartphone } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { useAuthStore } from '@/stores/auth-store' import { Select, SelectContent, @@ -18,8 +17,6 @@ import { setFrontendTheme, type FrontendTheme, } from '@/lib/frontend-theme' -import { updateFrontendTheme } from '../api' -import { parseUserSettings } from '../lib' const FRONTEND_THEME_OPTIONS: Array<{ value: FrontendTheme @@ -31,7 +28,6 @@ const FRONTEND_THEME_OPTIONS: Array<{ export function FrontendThemeCard() { const { t } = useTranslation() - const { auth } = useAuthStore() const [currentTheme, setCurrentTheme] = useState( getFrontendTheme() ) @@ -43,35 +39,13 @@ export function FrontendThemeCard() { setSwitching(true) try { - const existingSetting = - typeof auth.user?.setting === 'string' - ? parseUserSettings(auth.user.setting) - : (auth.user?.setting ?? {}) - const response = await updateFrontendTheme(nextTheme) - - if (!response.success) { - throw new Error(response.message || t('Failed to update settings')) - } - setFrontendTheme(nextTheme) setCurrentTheme(nextTheme) - if (auth.user) { - useAuthStore.getState().auth.setUser({ - ...auth.user, - setting: JSON.stringify({ - ...existingSetting, - frontend_theme: nextTheme, - }), - }) - } - toast.success(t('Interface style updated. Redirecting...')) window.setTimeout(() => { window.location.assign(getFrontendThemeSettingsPath(nextTheme)) }, 300) - } catch { - toast.error(t('Failed to update settings')) } finally { setSwitching(false) } diff --git a/web/default/src/lib/frontend-theme.ts b/web/default/src/lib/frontend-theme.ts index 1a39f007a78..3c76e4dffbe 100644 --- a/web/default/src/lib/frontend-theme.ts +++ b/web/default/src/lib/frontend-theme.ts @@ -1,7 +1,4 @@ -import { getCookie, setCookie } from '@/lib/cookies' - -export const FRONTEND_THEME_COOKIE_NAME = 'frontend_theme' -export const FRONTEND_THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 +export const ENABLED_CLASSIC_FRONTEND_KEY = 'EnabledClassicFrontend' export type FrontendTheme = 'default' | 'classic' @@ -15,11 +12,16 @@ export function normalizeFrontendTheme( } export function getFrontendTheme(): FrontendTheme { - return normalizeFrontendTheme(getCookie(FRONTEND_THEME_COOKIE_NAME)) + const enabledClassic = localStorage.getItem(ENABLED_CLASSIC_FRONTEND_KEY) + return enabledClassic === 'true' ? 'classic' : 'default' } export function setFrontendTheme(theme: FrontendTheme): void { - setCookie(FRONTEND_THEME_COOKIE_NAME, theme, FRONTEND_THEME_COOKIE_MAX_AGE) + if (theme === 'classic') { + localStorage.setItem(ENABLED_CLASSIC_FRONTEND_KEY, 'true') + } else { + localStorage.removeItem(ENABLED_CLASSIC_FRONTEND_KEY) + } } export function getFrontendThemeSettingsPath(theme: FrontendTheme): string { diff --git a/web/default/src/routes/_authenticated/route.tsx b/web/default/src/routes/_authenticated/route.tsx index 12dfe6d44a4..c228760ca2f 100644 --- a/web/default/src/routes/_authenticated/route.tsx +++ b/web/default/src/routes/_authenticated/route.tsx @@ -1,17 +1,15 @@ import { createFileRoute, redirect } from '@tanstack/react-router' import { useAuthStore } from '@/stores/auth-store' import { getSelf } from '@/lib/api' -import { normalizeFrontendTheme, setFrontendTheme } from '@/lib/frontend-theme' +import { getFrontendTheme } from '@/lib/frontend-theme' import { AuthenticatedLayout } from '@/components/layout' -// 内存中的验证标记,避免同一会话中重复验证 let sessionVerified = false export const Route = createFileRoute('/_authenticated')({ beforeLoad: async ({ location }) => { const { auth } = useAuthStore.getState() - // 如果本地没有用户信息,直接跳转登录页 if (!auth.user) { throw redirect({ to: '/sign-in', @@ -19,31 +17,12 @@ export const Route = createFileRoute('/_authenticated')({ }) } - // 本地有用户信息,但需要验证 session 是否有效(每个会话只验证一次) if (!sessionVerified) { const res = await getSelf().catch(() => null) if (res?.success && res.data) { - // 验证成功,更新用户信息(可能有变化) auth.setUser(res.data) - const setting = res.data?.setting - if (typeof setting === 'string') { - try { - const parsed = JSON.parse(setting) as { frontend_theme?: string } - if (parsed.frontend_theme) { - const normalizedTheme = normalizeFrontendTheme(parsed.frontend_theme) - setFrontendTheme(normalizedTheme) - if (normalizedTheme === 'classic') { - window.location.replace('/console') - return - } - } - } catch { - /* empty */ - } - } sessionVerified = true } else { - // 验证失败或 API 调用失败,清除本地缓存并跳转登录页 auth.reset() throw redirect({ to: '/sign-in', @@ -51,6 +30,12 @@ export const Route = createFileRoute('/_authenticated')({ }) } } + + const theme = getFrontendTheme() + if (theme === 'classic') { + window.location.replace('/console') + return + } }, component: AuthenticatedLayout, }) From f49f379305004ed35f9929b237ee08fc5909f22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A1=BE=E9=9B=A8=E6=99=A8?= Date: Sat, 23 May 2026 04:00:14 +0800 Subject: [PATCH 3/3] fix(main.go) Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index e77a54cec4f..1d4c4b13e2a 100644 --- a/main.go +++ b/main.go @@ -180,7 +180,7 @@ func main() { Path: "/", MaxAge: 2592000, HttpOnly: true, - Secure: os.Getenv("GIN_MODE") == "release", + Secure: gin.Mode() == gin.ReleaseMode, SameSite: http.SameSiteLaxMode, }) server.Use(sessions.Sessions("session", store))