mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 09:36:05 +00:00
fix(panel): make webBasePath work end-to-end in dev and prod
- Vite dev server reads webBasePath from x-ui.db via node:sqlite and injects __X_UI_BASE_PATH__ on every HTML serve, mirroring dist.go. Single broad proxy regex catches backend routes whether the URL is prefixed or not, and the bypass serves login.html for the bare basePath URL so post-logout navigation lands on Vite's own page instead of the production dist HTML's hashed asset URLs. - axios.defaults.baseURL is set from __X_UI_BASE_PATH__ at startup so HttpUtil calls reach the backend's basePath group instead of 404ing on every prefixed install. fetch() for the public CSRF endpoint prepends the prefix manually since it doesn't honor axios defaults. - Logout/redirect responses set Cache-Control: no-store and the index handler's logged-in redirect uses an absolute base_path+panel/ URL, preventing browsers from replaying a stale cached 307 that bounced the user back to /panel/ after logout. - ClearSession also issues a Path=/ deletion cookie when basePath is not "/", so a legacy cookie from an earlier basePath setting can't keep IsLogin returning true after logout. - getPanelUpdateInfo no longer returns a translated error message on GitHub fetch failures, so HttpUtil's auto-popup stays quiet on offline / blocked environments. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
72d8ebd269
commit
61c84e8223
6 changed files with 163 additions and 136 deletions
|
|
@ -22,7 +22,11 @@ function readMetaToken() {
|
||||||
// recurse through this same interceptor.
|
// recurse through this same interceptor.
|
||||||
async function fetchCsrfToken() {
|
async function fetchCsrfToken() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(CSRF_TOKEN_PATH, {
|
const basePath = window.__X_UI_BASE_PATH__;
|
||||||
|
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
||||||
|
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
|
||||||
|
: CSRF_TOKEN_PATH);
|
||||||
|
const res = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
|
@ -55,6 +59,11 @@ export function setupAxios() {
|
||||||
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
||||||
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||||
|
|
||||||
|
const basePath = window.__X_UI_BASE_PATH__;
|
||||||
|
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
|
||||||
|
axios.defaults.baseURL = basePath;
|
||||||
|
}
|
||||||
|
|
||||||
// Seed the cache from the meta tag if a server-rendered page injected
|
// Seed the cache from the meta tag if a server-rendered page injected
|
||||||
// one — saves a round trip on legacy templates that still embed it.
|
// one — saves a round trip on legacy templates that still embed it.
|
||||||
csrfToken = readMetaToken();
|
csrfToken = readMetaToken();
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,109 @@
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
// Output goes to web/dist/ at the repo root so the Go binary can embed it
|
|
||||||
// via embed.FS without reaching outside the web/ tree.
|
|
||||||
const outDir = path.resolve(__dirname, '../web/dist');
|
const outDir = path.resolve(__dirname, '../web/dist');
|
||||||
|
const BACKEND_TARGET = 'http://localhost:2053';
|
||||||
|
|
||||||
// In production the Go binary serves /panel/<route> from web/dist/<route>.html.
|
function resolveDBPath() {
|
||||||
// In dev the Vue app lives at /index.html, /settings.html, ... while AppSidebar
|
const envFolder = process.env.XUI_DB_FOLDER;
|
||||||
// links use the production-style /panel/<route> URLs. Map each migrated route
|
if (envFolder) return path.join(envFolder, 'x-ui.db');
|
||||||
// to its Vite entry so the sidebar works without relying on the Go backend
|
const repoDB = path.resolve(__dirname, '..', 'x-ui.db');
|
||||||
// for already-ported pages.
|
if (fs.existsSync(repoDB)) return repoDB;
|
||||||
const MIGRATED_ROUTES = {
|
return '/etc/x-ui/x-ui.db';
|
||||||
'/panel': '/index.html',
|
}
|
||||||
'/panel/': '/index.html',
|
|
||||||
'/panel/settings': '/settings.html',
|
const BASE_MIGRATED_ROUTES = {
|
||||||
'/panel/settings/': '/settings.html',
|
'panel': '/index.html',
|
||||||
'/panel/inbounds': '/inbounds.html',
|
'panel/': '/index.html',
|
||||||
'/panel/inbounds/': '/inbounds.html',
|
'panel/settings': '/settings.html',
|
||||||
'/panel/xray': '/xray.html',
|
'panel/settings/': '/settings.html',
|
||||||
'/panel/xray/': '/xray.html',
|
'panel/inbounds': '/inbounds.html',
|
||||||
'/panel/nodes': '/nodes.html',
|
'panel/inbounds/': '/inbounds.html',
|
||||||
'/panel/nodes/': '/nodes.html',
|
'panel/xray': '/xray.html',
|
||||||
|
'panel/xray/': '/xray.html',
|
||||||
|
'panel/nodes': '/nodes.html',
|
||||||
|
'panel/nodes/': '/nodes.html',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build a proxy config that suppresses ECONNREFUSED noise when the Go
|
let cachedBasePath = '/';
|
||||||
// backend isn't running locally. Real errors (timeouts, 5xx, etc.) still
|
|
||||||
// surface in the Vite log.
|
function readBasePathFromDB() {
|
||||||
function makeBackendProxy(target, patterns) {
|
const dbPath = resolveDBPath();
|
||||||
const config = {};
|
let db;
|
||||||
for (const pattern of patterns) {
|
try {
|
||||||
config[pattern] = {
|
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||||
target,
|
} catch (_e) {
|
||||||
changeOrigin: true,
|
return '/';
|
||||||
// Returning a path from bypass tells Vite to serve that file from
|
}
|
||||||
// its own dev server instead of forwarding the request — used here
|
try {
|
||||||
// to short-circuit /panel/<route> for pages we've already migrated.
|
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('webBasePath');
|
||||||
//
|
let value = row && typeof row.value === 'string' ? row.value : '/';
|
||||||
// Only GETs get bypassed: the xray page reuses its page URL
|
if (!value.startsWith('/')) value = '/' + value;
|
||||||
// (`POST /panel/xray/`) for data, so a method-blind bypass would
|
if (!value.endsWith('/')) value += '/';
|
||||||
// hand HTML back to fetch calls and break the page in dev.
|
return value;
|
||||||
bypass(req) {
|
} catch (_e) {
|
||||||
|
return '/';
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshBasePath() {
|
||||||
|
cachedBasePath = readBasePathFromDB();
|
||||||
|
return cachedBasePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `apply: 'serve'` keeps the injection out of `vite build` — dist.go
|
||||||
|
// already injects __X_UI_BASE_PATH__ at runtime in production.
|
||||||
|
function injectBasePathPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'xui-inject-base-path',
|
||||||
|
apply: 'serve',
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
const basePath = refreshBasePath();
|
||||||
|
const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
const tag = `<script>window.__X_UI_BASE_PATH__="${escaped}";</script>`;
|
||||||
|
return html.replace('</head>', `${tag}</head>`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bypassMigratedRoute(req) {
|
||||||
if (req.method !== 'GET') return undefined;
|
if (req.method !== 'GET') return undefined;
|
||||||
const url = req.url.split('?')[0];
|
const url = req.url.split('?')[0];
|
||||||
if (Object.prototype.hasOwnProperty.call(MIGRATED_ROUTES, url)) {
|
|
||||||
return MIGRATED_ROUTES[url];
|
for (const [key, value] of Object.entries(BASE_MIGRATED_ROUTES)) {
|
||||||
|
if (url === '/' + key) return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const m = url.match(/^\/[^/]+\/(.+)$/);
|
||||||
|
if (m) {
|
||||||
|
const stripped = m[1];
|
||||||
|
if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === '/' || /^\/[^/]+\/$/.test(url)) return '/login.html';
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
}
|
||||||
|
|
||||||
|
function rewriteToBackend(p) {
|
||||||
|
if (cachedBasePath === '/' || p.startsWith(cachedBasePath)) return p;
|
||||||
|
return cachedBasePath + p.replace(/^\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBackendProxy(target) {
|
||||||
|
return {
|
||||||
|
target,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: rewriteToBackend,
|
||||||
|
bypass: bypassMigratedRoute,
|
||||||
configure(proxy) {
|
configure(proxy) {
|
||||||
let warned = false;
|
let warned = false;
|
||||||
proxy.on('error', (err, req) => {
|
proxy.on('error', (err, req) => {
|
||||||
// Node wraps connection failures in an AggregateError when DNS
|
|
||||||
// returns multiple addresses (e.g. ::1 + 127.0.0.1) and all
|
|
||||||
// refuse — the code lands on the inner errors, not the outer.
|
|
||||||
const codes = new Set();
|
const codes = new Set();
|
||||||
if (err && err.code) codes.add(err.code);
|
if (err && err.code) codes.add(err.code);
|
||||||
if (err && Array.isArray(err.errors)) {
|
if (err && Array.isArray(err.errors)) {
|
||||||
|
|
@ -63,7 +113,6 @@ function makeBackendProxy(target, patterns) {
|
||||||
}
|
}
|
||||||
const offline = codes.has('ECONNREFUSED') || codes.has('ECONNRESET');
|
const offline = codes.has('ECONNREFUSED') || codes.has('ECONNRESET');
|
||||||
if (offline) {
|
if (offline) {
|
||||||
// Print a single friendly hint the first time, then stay quiet.
|
|
||||||
if (!warned) {
|
if (!warned) {
|
||||||
warned = true;
|
warned = true;
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
|
|
@ -78,12 +127,10 @@ function makeBackendProxy(target, patterns) {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
|
||||||
return config;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue(), injectBasePathPlugin()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
|
@ -94,14 +141,7 @@ export default defineConfig({
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
target: 'es2020',
|
target: 'es2020',
|
||||||
// ant-design-vue is intentionally bundled as one chunk (its
|
|
||||||
// components share internals — splitting it breaks Modal/Form/
|
|
||||||
// Select interop). Minified it lands ~1.4MB but gzips to ~410kB,
|
|
||||||
// so the actual transfer is fine and caches across every page.
|
|
||||||
// Bump the warning past that ceiling so the build stays quiet.
|
|
||||||
chunkSizeWarningLimit: 1500,
|
chunkSizeWarningLimit: 1500,
|
||||||
// Multiple HTML entries — one per legacy page we migrate.
|
|
||||||
// As pages get ported in later phases, add their entrypoints here.
|
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
index: path.resolve(__dirname, 'index.html'),
|
index: path.resolve(__dirname, 'index.html'),
|
||||||
|
|
@ -113,10 +153,6 @@ export default defineConfig({
|
||||||
subpage: path.resolve(__dirname, 'subpage.html'),
|
subpage: path.resolve(__dirname, 'subpage.html'),
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
// Split vendor deps into stable chunks so each page only pulls
|
|
||||||
// what it needs and the browser caches them across versions.
|
|
||||||
// Without this, ant-design-vue + vue + icons all end up in one
|
|
||||||
// 1.6MB blob attached to whichever page consumed them first.
|
|
||||||
manualChunks(id) {
|
manualChunks(id) {
|
||||||
if (!id.includes('node_modules')) return undefined;
|
if (!id.includes('node_modules')) return undefined;
|
||||||
if (id.includes('ant-design-vue')) return 'vendor-antd';
|
if (id.includes('ant-design-vue')) return 'vendor-antd';
|
||||||
|
|
@ -129,8 +165,6 @@ export default defineConfig({
|
||||||
if (id.includes('dayjs')) return 'vendor-dayjs';
|
if (id.includes('dayjs')) return 'vendor-dayjs';
|
||||||
if (id.includes('qrious')) return 'vendor-qrious';
|
if (id.includes('qrious')) return 'vendor-qrious';
|
||||||
if (id.includes('axios')) return 'vendor-axios';
|
if (id.includes('axios')) return 'vendor-axios';
|
||||||
// The persian datepicker pulls in moment + moment-jalaali; bundle
|
|
||||||
// the trio together so unrelated pages don't pay the cost.
|
|
||||||
if (
|
if (
|
||||||
id.includes('vue3-persian-datetime-picker')
|
id.includes('vue3-persian-datetime-picker')
|
||||||
|| id.includes('moment-jalaali')
|
|| id.includes('moment-jalaali')
|
||||||
|
|
@ -146,21 +180,14 @@ export default defineConfig({
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
...makeBackendProxy('http://localhost:2053', [
|
'^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET),
|
||||||
// Patterns are anchored regex so /login.html and /index.html
|
'^/$': makeBackendProxy(BACKEND_TARGET),
|
||||||
// (which Vite serves itself) are NOT forwarded — only the bare
|
'^/[^/]+/$': makeBackendProxy(BACKEND_TARGET),
|
||||||
// backend paths and their sub-routes.
|
'^/(?:[^/]+/)?ws$': {
|
||||||
'^/(login|logout|getTwoFactorEnable|csrf-token)$',
|
|
||||||
'^/(panel|server)(/|$)',
|
|
||||||
]),
|
|
||||||
// The panel mounts the live-update WebSocket at /ws (basePath +
|
|
||||||
// "/ws"). Vite needs `ws: true` to forward the HTTP Upgrade to the
|
|
||||||
// Go backend; without it the dev server would 404 the upgrade and
|
|
||||||
// the page falls back to the no-data state.
|
|
||||||
'/ws': {
|
|
||||||
target: 'ws://localhost:2053',
|
target: 'ws://localhost:2053',
|
||||||
ws: true,
|
ws: true,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
rewrite: rewriteToBackend,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ func (a *BaseController) checkLogin(c *gin.Context) {
|
||||||
if isAjax(c) {
|
if isAjax(c) {
|
||||||
pureJsonMsg(c, http.StatusUnauthorized, false, I18nWeb(c, "pages.login.loginAgain"))
|
pureJsonMsg(c, http.StatusUnauthorized, false, I18nWeb(c, "pages.login.loginAgain"))
|
||||||
} else {
|
} else {
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
|
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
|
||||||
}
|
}
|
||||||
c.Abort()
|
c.Abort()
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,8 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) {
|
||||||
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
|
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
|
||||||
func (a *IndexController) index(c *gin.Context) {
|
func (a *IndexController) index(c *gin.Context) {
|
||||||
if session.IsLogin(c) {
|
if session.IsLogin(c) {
|
||||||
c.Redirect(http.StatusTemporaryRedirect, "panel/")
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path")+"panel/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
serveDistPage(c, "login.html")
|
serveDistPage(c, "login.html")
|
||||||
|
|
@ -148,6 +149,7 @@ func (a *IndexController) logout(c *gin.Context) {
|
||||||
if err := session.ClearSession(c); err != nil {
|
if err := session.ClearSession(c); err != nil {
|
||||||
logger.Warning("Unable to clear session on logout:", err)
|
logger.Warning("Unable to clear session on logout:", err)
|
||||||
}
|
}
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
|
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,17 +164,11 @@ func (a *ServerController) getXrayVersion(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// getPanelUpdateInfo retrieves the current and latest panel version.
|
// getPanelUpdateInfo retrieves the current and latest panel version.
|
||||||
// Network failures (e.g. no internet, GitHub blocked) are logged at debug
|
|
||||||
// level only — the panel keeps working offline and we don't want to spam
|
|
||||||
// WARN every time a user opens the page.
|
|
||||||
func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
|
func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
|
||||||
info, err := a.panelService.GetUpdateInfo()
|
info, err := a.panelService.GetUpdateInfo()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debug("panel update check failed:", err)
|
logger.Debug("panel update check failed:", err)
|
||||||
c.JSON(http.StatusOK, entity.Msg{
|
c.JSON(http.StatusOK, entity.Msg{Success: false})
|
||||||
Success: false,
|
|
||||||
Msg: I18nWeb(c, "pages.index.panelUpdateCheckPopover"),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonObj(c, info, nil)
|
jsonObj(c, info, nil)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
// Package session provides session management utilities for the 3x-ui web panel.
|
|
||||||
// It handles user authentication state, login sessions, and session storage using Gin sessions.
|
|
||||||
package session
|
package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
|
@ -15,23 +14,14 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
loginUserKey = "LOGIN_USER"
|
loginUserKey = "LOGIN_USER"
|
||||||
// apiAuthUserKey is the gin-context key under which checkAPIAuth
|
|
||||||
// stashes a fallback user for Bearer-token-authenticated callers.
|
|
||||||
// Bearer requests don't carry a session cookie, so handlers that
|
|
||||||
// scope writes by user.Id (e.g. InboundController.addInbound) would
|
|
||||||
// otherwise nil-deref. Keeping the override in the gin context
|
|
||||||
// (not the cookie session) means the fallback never leaks into a
|
|
||||||
// browser request.
|
|
||||||
apiAuthUserKey = "api_auth_user"
|
apiAuthUserKey = "api_auth_user"
|
||||||
|
sessionCookieName = "3x-ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
gob.Register(model.User{})
|
gob.Register(model.User{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLoginUser stores the authenticated user in the session and persists it.
|
|
||||||
// gin-contrib/sessions does not auto-save; callers that forget Save() leave
|
|
||||||
// the cookie out of sync with server state — this helper avoids that pitfall.
|
|
||||||
func SetLoginUser(c *gin.Context, user *model.User) error {
|
func SetLoginUser(c *gin.Context, user *model.User) error {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -41,10 +31,6 @@ func SetLoginUser(c *gin.Context, user *model.User) error {
|
||||||
return s.Save()
|
return s.Save()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAPIAuthUser stashes a fallback user on the gin context for the
|
|
||||||
// lifetime of a single bearer-authed request. checkAPIAuth calls this
|
|
||||||
// after a successful token match so downstream handlers that read
|
|
||||||
// GetLoginUser don't see nil.
|
|
||||||
func SetAPIAuthUser(c *gin.Context, user *model.User) {
|
func SetAPIAuthUser(c *gin.Context, user *model.User) {
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return
|
return
|
||||||
|
|
@ -52,8 +38,6 @@ func SetAPIAuthUser(c *gin.Context, user *model.User) {
|
||||||
c.Set(apiAuthUserKey, user)
|
c.Set(apiAuthUserKey, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLoginUser retrieves the authenticated user from the session.
|
|
||||||
// Returns nil if no user is logged in or if the session data is invalid.
|
|
||||||
func GetLoginUser(c *gin.Context) *model.User {
|
func GetLoginUser(c *gin.Context) *model.User {
|
||||||
if v, ok := c.Get(apiAuthUserKey); ok {
|
if v, ok := c.Get(apiAuthUserKey); ok {
|
||||||
if u, ok2 := v.(*model.User); ok2 {
|
if u, ok2 := v.(*model.User); ok2 {
|
||||||
|
|
@ -67,8 +51,6 @@ func GetLoginUser(c *gin.Context) *model.User {
|
||||||
}
|
}
|
||||||
user, ok := obj.(model.User)
|
user, ok := obj.(model.User)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Stale or incompatible session payload — wipe and persist immediately
|
|
||||||
// so subsequent requests don't keep hitting the same broken cookie.
|
|
||||||
s.Delete(loginUserKey)
|
s.Delete(loginUserKey)
|
||||||
if err := s.Save(); err != nil {
|
if err := s.Save(); err != nil {
|
||||||
logger.Warning("session: failed to drop stale user payload:", err)
|
logger.Warning("session: failed to drop stale user payload:", err)
|
||||||
|
|
@ -78,14 +60,10 @@ func GetLoginUser(c *gin.Context) *model.User {
|
||||||
return &user
|
return &user
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsLogin checks if a user is currently authenticated in the session.
|
|
||||||
func IsLogin(c *gin.Context) bool {
|
func IsLogin(c *gin.Context) bool {
|
||||||
return GetLoginUser(c) != nil
|
return GetLoginUser(c) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearSession invalidates the session and tells the browser to drop the cookie.
|
|
||||||
// The cookie attributes (Path/HttpOnly/SameSite) must mirror those used when
|
|
||||||
// the cookie was created or browsers will keep it.
|
|
||||||
func ClearSession(c *gin.Context) error {
|
func ClearSession(c *gin.Context) error {
|
||||||
s := sessions.Default(c)
|
s := sessions.Default(c)
|
||||||
s.Clear()
|
s.Clear()
|
||||||
|
|
@ -93,12 +71,28 @@ func ClearSession(c *gin.Context) error {
|
||||||
if cookiePath == "" {
|
if cookiePath == "" {
|
||||||
cookiePath = "/"
|
cookiePath = "/"
|
||||||
}
|
}
|
||||||
|
secure := c.Request.TLS != nil
|
||||||
s.Options(sessions.Options{
|
s.Options(sessions.Options{
|
||||||
Path: cookiePath,
|
Path: cookiePath,
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: c.Request.TLS != nil,
|
Secure: secure,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
return s.Save()
|
if err := s.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cookiePath != "/" {
|
||||||
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue