diff --git a/.env.example b/.env.example index ca58549..d9e8824 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,10 @@ SESSION_SECRET= # The endpoint returns 401 if this is unset or the token does not match. # ADMIN_PANEL_METRICS_SECRET= +# URL base path for serving the admin panel under a subpath (e.g., /adminpanel). +# Must match at build time and runtime. Leave unset for root (/). +# VITE_BASE_PATH=/adminpanel + # Browser-facing URL of the LibreChat API server (used for OAuth redirects). # Defaults to http://localhost:3080 # VITE_API_BASE_URL=http://localhost:3080 diff --git a/Dockerfile b/Dockerfile index 0c6cc62..7dc6cc4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,8 @@ RUN bun install --frozen-lockfile FROM base AS build COPY --from=deps /app/node_modules node_modules COPY . . +ARG VITE_BASE_PATH=/ +ENV VITE_BASE_PATH=${VITE_BASE_PATH} ENV NODE_ENV=production RUN bun run build @@ -39,7 +41,7 @@ USER bun ENV PORT=3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD bun -e "fetch(\`http://localhost:\${process.env.PORT}\`).then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))" + CMD bun -e "fetch(\`http://localhost:\${process.env.PORT}/health\`).then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))" EXPOSE 3000 CMD ["bun", "run", "start"] diff --git a/README.md b/README.md index df3bf99..74584b6 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ docker compose down # stop | `PORT` | No | `3000` | Port the admin panel listens on | | `SESSION_SECRET` | **Yes** (always required in Docker) | Dev fallback only when running `bun dev` locally; no default in the Docker image | Encryption key for sessions (min 32 chars) | | `VITE_API_BASE_URL` | **Yes** (Docker) | `http://localhost:3080` (local dev only) | LibreChat API server URL; use `http://host.docker.internal:` in Docker | +| `VITE_BASE_PATH` | No | `/` | URL subpath to serve the panel under (e.g., `/adminpanel`). Must match at build time and runtime | | `API_SERVER_URL` | No | Falls back to `VITE_API_BASE_URL` | Server-side LibreChat API URL when the container reaches LibreChat differently than the browser | | `ADMIN_SSO_ONLY` | No | `false` | Hide email/password form, SSO only | | `ADMIN_SESSION_IDLE_TIMEOUT_MS` | No | `1800000` (30 min) | Session idle timeout in ms | @@ -66,4 +67,13 @@ docker run -p 3000:3000 \ -e VITE_API_BASE_URL=http://host.docker.internal:3080 \ -e SESSION_COOKIE_SECURE=false \ librechat-admin-panel + +# To serve under a subpath (e.g., /adminpanel): +docker build -t librechat-admin-panel --build-arg VITE_BASE_PATH=/adminpanel . +docker run -p 3000:3000 \ + --add-host=host.docker.internal:host-gateway \ + -e SESSION_SECRET=your-secret-here-at-least-32-characters \ + -e VITE_API_BASE_URL=http://host.docker.internal:3080 \ + -e VITE_BASE_PATH=/adminpanel \ + librechat-admin-panel ``` diff --git a/server.ts b/server.ts index 70ef218..d5457a5 100644 --- a/server.ts +++ b/server.ts @@ -11,6 +11,7 @@ const CLIENT_DIR = join(import.meta.dir, 'dist', 'client'); const SERVER_ENTRY = new URL('./dist/server/server.js', import.meta.url); const env = process.env; +const BASE_PATH = (env.VITE_BASE_PATH || '').replace(/\/$/, ''); const ONE_DAY = 86400; const rawMaxAge = Number(env.ADMIN_PANEL_STATIC_CACHE_MAX_AGE ?? env.STATIC_CACHE_MAX_AGE); @@ -63,7 +64,7 @@ async function buildStaticRoutes(): Promise Pro for await (const path of new Glob('**/*').scan(CLIENT_DIR)) { const file = Bun.file(`${CLIENT_DIR}/${path}`); const cache = getCacheHeaders(path); - const routePath = `/${path}`; + const routePath = `${BASE_PATH}/${path}`; routes[routePath] = (req) => withHttpMetrics( req, @@ -79,9 +80,14 @@ const server = Bun.serve({ routes: { ...(await buildStaticRoutes()), '/metrics': (req) => metricsResponse(req), + '/health': () => new Response('ok'), + ...(BASE_PATH ? { [`${BASE_PATH}`]: () => Response.redirect(`${BASE_PATH}/`, 302) } : {}), '/*': async (req) => { const url = new URL(req.url); - const res = await withHttpMetrics(req, url.pathname, () => handler.fetch(req)); + const metricsPath = BASE_PATH && url.pathname.startsWith(BASE_PATH) + ? url.pathname.slice(BASE_PATH.length) || '/' + : url.pathname; + const res = await withHttpMetrics(req, metricsPath, () => handler.fetch(req)); const patched = new Response(res.body, res); for (const [k, v] of Object.entries(NO_CACHE)) { patched.headers.set(k, v); @@ -91,7 +97,7 @@ const server = Bun.serve({ }, }); -console.log(`Admin panel listening on http://localhost:${server.port}`); +console.log(`Admin panel listening on http://localhost:${server.port}${BASE_PATH}/`); if (!process.env.ADMIN_PANEL_METRICS_SECRET) { console.warn( diff --git a/src/router.tsx b/src/router.tsx index 03de35b..8f38e33 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -7,6 +7,7 @@ export function getRouter() { const queryClient = new QueryClient(); const router = createTanStackRouter({ routeTree, + basepath: import.meta.env.VITE_BASE_PATH || '/', context: { queryClient }, scrollRestoration: true, defaultPreload: 'intent', diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index fcc82e7..18d99af 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -46,7 +46,7 @@ export const Route = createRootRoute({ }, { rel: 'icon', - href: '/favicon.ico', + href: `${(import.meta.env.VITE_BASE_PATH || '').replace(/\/$/, '')}/favicon.ico`, }, ], }), diff --git a/src/routes/auth/openid/callback.tsx b/src/routes/auth/openid/callback.tsx index 09153ae..4472304 100644 --- a/src/routes/auth/openid/callback.tsx +++ b/src/routes/auth/openid/callback.tsx @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { createFileRoute, redirect } from '@tanstack/react-router'; +import { createFileRoute, redirect, Link } from '@tanstack/react-router'; import { oauthExchangeFn } from '@/server'; import { useLocalize } from '@/hooks'; @@ -62,12 +62,13 @@ function OpenIdCallback() { {localize('com_auth_sso_error_title')}

{errorMessage}

- {localize('com_auth_sso_back_to_login')} - + ); diff --git a/src/server/auth.ts b/src/server/auth.ts index 240d400..9a1bf77 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -347,13 +347,10 @@ export const openidLoginFn = createServerFn({ method: 'GET' }).handler(async () try { const baseUrl = getApiBaseUrl(); const authUrl = new URL(`${baseUrl}/api/admin/oauth/openid`); - const requestOrigin = getRequestOrigin(); const codeVerifier = crypto.randomBytes(32).toString('hex'); const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('hex'); authUrl.searchParams.set('code_challenge', codeChallenge); - if (requestOrigin) - authUrl.searchParams.set('redirect_uri', `${requestOrigin}/auth/openid/callback`); const session = await useAppSession(); await session.update({ codeVerifier }); diff --git a/src/server/session.ts b/src/server/session.ts index 28f7546..38a00a7 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -40,11 +40,14 @@ if (!process.env.SESSION_SECRET && process.env.NODE_ENV === 'development') { ); } +const sessionCookiePath = process.env.VITE_BASE_PATH || '/'; + export function useAppSession(): ReturnType> { return useSession({ name: 'admin-session', password: sessionSecret || '', cookie: { + path: sessionCookiePath, secure: sessionCookieSecure, sameSite: 'lax', httpOnly: true, diff --git a/vite.config.ts b/vite.config.ts index 832ae92..0585c77 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,7 @@ import viteReact from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' const config = defineConfig({ + base: process.env.VITE_BASE_PATH || '/', plugins: [ devtools(), tailwindcss(),