From d01ffb0d5593b784e1ee43969f0167e773995b4e Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 29 Jun 2026 09:14:37 +0800 Subject: [PATCH] feat(web): support context path deployments --- README.md | 11 ++++ README.zh-CN.md | 9 ++++ apps/desktop/src/App.vue | 7 +-- .../desktop/src/components/auth/LoginPage.vue | 3 +- .../editor/EditorSettingsDialog.vue | 3 +- .../desktop/src/lib/__tests__/webPath.spec.ts | 32 +++++++++++ apps/desktop/src/lib/api.ts | 4 +- apps/desktop/src/lib/http.ts | 47 ++++++++-------- apps/desktop/src/lib/httpSqlFileProgress.ts | 3 +- apps/desktop/src/lib/mq-http.ts | 3 +- apps/desktop/src/lib/tabResultCache.ts | 7 +-- apps/desktop/src/lib/webPath.ts | 39 ++++++++++++++ apps/desktop/vite.config.ts | 17 +++++- crates/dbx-web/src/auth.rs | 48 ++++++++++++++--- crates/dbx-web/src/main.rs | 54 +++++++++++++++++++ crates/dbx-web/src/routes/connection.rs | 1 + crates/dbx-web/src/state.rs | 1 + deploy/docker-compose.yml | 2 + 18 files changed, 248 insertions(+), 43 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/webPath.spec.ts create mode 100644 apps/desktop/src/lib/webPath.ts diff --git a/README.md b/README.md index ffe46057a..5355454f0 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,17 @@ volumes: Open `http://localhost:4224` in your browser. Multi-arch images (amd64 / arm64) are available. +To publish DBX under a reverse-proxy context path such as `/dbx`, set the +runtime base path and proxy the same prefix to the container: + +```yaml +environment: + - DBX_PUBLIC_BASE_PATH=/dbx +``` + +When building the frontend yourself with an absolute asset base, set +`VITE_DBX_BASE_PATH=/dbx/` before `pnpm build`. + ## Getting Started ### Prerequisites diff --git a/README.zh-CN.md b/README.zh-CN.md index 630fa23ea..9db69c54d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -218,6 +218,15 @@ volumes: dbx-data: ``` +如需通过 nginx 等反向代理发布到 `/dbx` 这类子路径下,设置运行时上下文路径,并将同一前缀代理到容器: + +```yaml +environment: + - DBX_PUBLIC_BASE_PATH=/dbx +``` + +如果自行从源码构建前端并希望使用绝对资源路径,可在 `pnpm build` 前设置 `VITE_DBX_BASE_PATH=/dbx/`。 + 浏览器访问 `http://localhost:4224`。支持 amd64 / arm64 双架构镜像。 ## 快速开始 diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 5b4187f2f..2e511e797 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -68,6 +68,7 @@ import { classifyAiSqlExecution } from "@/lib/aiSqlExecutionPolicy"; import { buildHistoryAiAnalysisPrompt } from "@/lib/historyAiAnalysis"; import { countAvailableAgentDriverUpdates, type AgentDriverUpdateBadgeState } from "@/lib/agentDriverUpdateBadge"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/safeStorage"; +import { apiUrl, webPath } from "@/lib/webPath"; import { rankSavedSqlHistory } from "@/lib/savedSqlHistory"; import { isSchemaAware, isSingleDatabase, usesTreeSchemaMode } from "@/lib/databaseFeatureSupport"; import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect"; @@ -1263,7 +1264,7 @@ function onLoginSuccess() { authenticated.value = true; setupRequired.value = false; needsAuth.value = true; - window.history.replaceState(null, "", "/"); + window.history.replaceState(null, "", webPath("/")); initApp(); } @@ -1386,7 +1387,7 @@ onMounted(async () => { ); if (!isDesktop) { try { - const res = await fetch("/api/auth/check"); + const res = await fetch(apiUrl("/api/auth/check")); const data = await res.json(); needsAuth.value = data.required; authenticated.value = data.authenticated; @@ -1395,7 +1396,7 @@ onMounted(async () => { /* server unreachable */ } if (needsAuth.value && !authenticated.value) { - history.replaceState(null, "", "/login"); + history.replaceState(null, "", webPath("/login")); } if (!setupRequired.value && (!needsAuth.value || authenticated.value)) initApp(); api diff --git a/apps/desktop/src/components/auth/LoginPage.vue b/apps/desktop/src/components/auth/LoginPage.vue index 449dc8b95..54e2850af 100644 --- a/apps/desktop/src/components/auth/LoginPage.vue +++ b/apps/desktop/src/components/auth/LoginPage.vue @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import PasswordInput from "@/components/ui/PasswordInput.vue"; import { Lock, Loader2, ShieldCheck } from "@lucide/vue"; import AppLogo from "@/components/icons/AppLogo.vue"; +import { apiUrl } from "@/lib/webPath"; const props = withDefaults( defineProps<{ @@ -30,7 +31,7 @@ async function submit() { loading.value = true; error.value = ""; try { - const url = props.setupMode ? "/api/auth/setup" : "/api/auth/login"; + const url = apiUrl(props.setupMode ? "/api/auth/setup" : "/api/auth/login"); const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index a85906ecb..c69d84e28 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -80,6 +80,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { currentLocale, setLocale, type Locale } from "@/i18n"; import { LOCALE_OPTIONS } from "@/lib/localeOptions"; import { DEFAULT_WEB_DAV_AUTO_UPLOAD_INTERVAL_MINUTES, DEFAULT_WEB_DAV_REMOTE_PATH, normalizedWebDavAutoUploadInterval, writeWebDavAutoUploadFields } from "@/lib/webdavAutoUploadConfig"; +import { apiUrl } from "@/lib/webPath"; const { t } = useI18n(); const settingsStore = useSettingsStore(); @@ -1386,7 +1387,7 @@ async function changePassword() { changingPassword.value = true; passwordMessage.value = ""; try { - const res = await fetch("/api/auth/change-password", { + const res = await fetch(apiUrl("/api/auth/change-password"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ old_password: oldPassword.value, new_password: newPassword.value }), diff --git a/apps/desktop/src/lib/__tests__/webPath.spec.ts b/apps/desktop/src/lib/__tests__/webPath.spec.ts new file mode 100644 index 000000000..d2c9b2df6 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/webPath.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { apiUrl, apiWebSocketUrl, dbxWebBasePath, webPath } from "@/lib/webPath"; + +describe("webPath", () => { + it("keeps root deployments on root-relative API paths", () => { + expect(dbxWebBasePath("/", "/")).toBe(""); + expect(apiUrl("/api/auth/check", "")).toBe("/api/auth/check"); + }); + + it("uses an explicit build base path", () => { + expect(dbxWebBasePath("/", "/dbx/")).toBe("/dbx"); + expect(webPath("/login", "/dbx")).toBe("/dbx/login"); + expect(webPath("/", "/dbx")).toBe("/dbx/"); + expect(apiUrl("/auth/check", "/dbx")).toBe("/dbx/api/auth/check"); + expect(apiUrl("/api/auth/check", "/dbx")).toBe("/dbx/api/auth/check"); + expect(apiUrl("api/auth/check", "/dbx")).toBe("/dbx/api/auth/check"); + }); + + it("infers the runtime base path from the login URL for relative builds", () => { + expect(dbxWebBasePath("/dbx/login", "./")).toBe("/dbx"); + expect(dbxWebBasePath("/tools/dbx/login", "./")).toBe("/tools/dbx"); + }); + + it("infers the runtime base path from a mounted relative build", () => { + expect(dbxWebBasePath("/dbx/", "./")).toBe("/dbx"); + expect(dbxWebBasePath("/tools/dbx/", "./")).toBe("/tools/dbx"); + }); + + it("builds websocket URLs with the configured base path", () => { + expect(apiWebSocketUrl("/redis/session/123", "/dbx", { protocol: "https:", host: "example.test" })).toBe("wss://example.test/dbx/api/redis/session/123"); + }); +}); diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index fa539e6f6..1755e4c88 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -1,6 +1,7 @@ import { isTauriRuntime } from "./tauriRuntime"; import type * as TauriModule from "./tauri"; import { appendDebugLog } from "./debugLog"; +import { apiWebSocketUrl } from "./webPath"; // --------------------------------------------------------------------------- // Lazy backend resolution (avoids top-level await) @@ -305,8 +306,7 @@ export const redisSlowlogGet = forward("redisSlowlogGet"); export const redisClusterMasterNodes = forward("redisClusterMasterNodes"); export function redisPubSubConnect(connectionId: string): WebSocket { - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - return new WebSocket(`${protocol}//${window.location.host}/api/redis/pubsub/ws?connectionId=${encodeURIComponent(connectionId)}`); + return new WebSocket(apiWebSocketUrl(`/api/redis/pubsub/ws?connectionId=${encodeURIComponent(connectionId)}`)); } // etcd diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index f22365bb4..c2b221f12 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -111,6 +111,7 @@ import type { CreateDatabaseSqlOptions } from "@/lib/createDatabaseSql"; import type { DatabaseNameSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/dbAdminSql"; import type { BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions } from "@/lib/databaseExport"; import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataCompare"; +import { apiUrl } from "@/lib/webPath"; import type { DataGridSavePreparation } from "./tauri"; import type { NacosConfigHistoryKey, @@ -155,7 +156,7 @@ const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { }; async function post(url: string, body: unknown): Promise { - const res = await fetch(url, { + const res = await fetch(apiUrl(url), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -165,13 +166,13 @@ async function post(url: string, body: unknown): Promise { } async function get(url: string): Promise { - const res = await fetch(url); + const res = await fetch(apiUrl(url)); if (!res.ok) throw new Error(await res.text()); return res.json(); } async function del(url: string): Promise { - const res = await fetch(url, { method: "DELETE" }); + const res = await fetch(apiUrl(url), { method: "DELETE" }); if (!res.ok) throw new Error(await res.text()); return res.json(); } @@ -259,7 +260,7 @@ export async function importJdbcDrivers(pathsOrFiles: (string | File)[]): Promis formData.append("files", blob, fileName); } } - const res = await fetch("/api/jdbc/drivers", { method: "POST", body: formData }); + const res = await fetch(apiUrl("/api/jdbc/drivers"), { method: "POST", body: formData }); if (!res.ok) throw new Error(await res.text()); return res.json(); } @@ -301,7 +302,7 @@ export async function installJdbcPluginLocal(pathOrFile: string | File): Promise } const formData = new FormData(); formData.append("file", blob, fileName); - const uploadRes = await fetch("/api/jdbc/plugin/install-local", { method: "POST", body: formData }); + const uploadRes = await fetch(apiUrl("/api/jdbc/plugin/install-local"), { method: "POST", body: formData }); if (!uploadRes.ok) throw new Error(await uploadRes.text()); return uploadRes.json(); } @@ -368,7 +369,7 @@ export async function importAgentsFromZip(fileOrPath: string | File): Promise { } export async function listenAgentInstallProgress(handler: (progress: DriverInstallProgress) => void): Promise<() => void> { - const es = new EventSource("/api/agents/progress/global"); + const es = new EventSource(apiUrl("/api/agents/progress/global")); es.onmessage = (event) => { try { handler(JSON.parse(event.data)); @@ -872,7 +873,7 @@ export async function aiComplete(request: AiCompletionRequest): Promise } export async function aiStream(sessionId: string, request: AiCompletionRequest, onChunk: (chunk: AiStreamChunk) => void): Promise { - const res = await fetch("/api/ai/stream", { + const res = await fetch(apiUrl("/api/ai/stream"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId, request }), @@ -927,7 +928,7 @@ function isAgentEvent(v: unknown): v is import("./tauri").AgentEvent { } export async function aiAgentStream(sessionId: string, request: AiCompletionRequest, connectionId: string, database: string, dbType: string, onEvent: (event: import("./tauri").AgentEvent) => void, mode?: string, signal?: AbortSignal): Promise { - const res = await fetch("/api/ai/agent-stream", { + const res = await fetch(apiUrl("/api/ai/agent-stream"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId, request, connectionId, database, dbType, mode: mode || "ask" }), @@ -1112,7 +1113,7 @@ export async function previewSqlFile(fileOrPath: string | File): Promise void): Promise { // 1. POST to start the transfer - const res = await fetch("/api/transfer/start", { + const res = await fetch(apiUrl("/api/transfer/start"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request }), @@ -1168,7 +1169,7 @@ export async function startTransfer(request: TransferRequest, onProgress: (progr // 2. SSE to listen for progress return new Promise((resolve, reject) => { - const es = new EventSource(`/api/transfer/progress/${request.transferId}`); + const es = new EventSource(apiUrl(`/api/transfer/progress/${request.transferId}`)); es.onmessage = (e) => { const progress: TransferProgress = JSON.parse(e.data); onProgress(progress); @@ -1210,14 +1211,14 @@ export async function previewTableImportFile(fileOrPath: string | File): Promise } const formData = new FormData(); formData.append("file", fileOrPath); - const res = await fetch("/api/import/preview", { method: "POST", body: formData }); + const res = await fetch(apiUrl("/api/import/preview"), { method: "POST", body: formData }); if (!res.ok) throw new Error(await res.text()); return res.json(); } export async function importTableFile(request: TableImportRequest, onProgress: (progress: TableImportProgress) => void): Promise { // 1. POST to start the import - const res = await fetch("/api/import/execute", { + const res = await fetch(apiUrl("/api/import/execute"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request }), @@ -1226,7 +1227,7 @@ export async function importTableFile(request: TableImportRequest, onProgress: ( // 2. SSE to listen for progress return new Promise((resolve, reject) => { - const es = new EventSource(`/api/import/progress/${request.importId}`); + const es = new EventSource(apiUrl(`/api/import/progress/${request.importId}`)); let summary: TableImportSummary | null = null; es.onmessage = (e) => { const progress: TableImportProgress = JSON.parse(e.data); @@ -1261,7 +1262,7 @@ export async function cancelTableImport(importId: string): Promise { export async function exportDatabaseSql(request: DatabaseExportRequest, onProgress: (progress: ExportProgress) => void): Promise { // 1. POST to start the export - const res = await fetch("/api/export/database", { + const res = await fetch(apiUrl("/api/export/database"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request }), @@ -1270,7 +1271,7 @@ export async function exportDatabaseSql(request: DatabaseExportRequest, onProgre // 2. SSE to listen for progress return new Promise((resolve, reject) => { - const es = new EventSource(`/api/export/database/progress/${request.exportId}`); + const es = new EventSource(apiUrl(`/api/export/database/progress/${request.exportId}`)); es.onmessage = (e) => { const progress: ExportProgress = JSON.parse(e.data); onProgress(progress); @@ -1293,7 +1294,7 @@ export async function exportDatabaseSql(request: DatabaseExportRequest, onProgre function downloadDatabaseExportFile(exportId: string): void { const a = document.createElement("a"); - a.href = `/api/export/database/download/${exportId}`; + a.href = apiUrl(`/api/export/database/download/${exportId}`); a.click(); } @@ -1309,7 +1310,7 @@ export async function startTableExport(request: TableExportRequest, onProgress: return new Promise((resolve, reject) => { let started = false; let settled = false; - const eventSource = new EventSource(`/api/export/table/progress/${exportId}`); + const eventSource = new EventSource(apiUrl(`/api/export/table/progress/${exportId}`)); const finish = (callback: () => void) => { if (settled) return; @@ -1351,7 +1352,7 @@ export async function startTableExport(request: TableExportRequest, onProgress: function downloadTableExportFile(exportId: string, format: string): void { const ext = format === "markdown" || format === "md" ? "md" : format; const a = document.createElement("a"); - a.href = `/api/export/table/download/${exportId}`; + a.href = apiUrl(`/api/export/table/download/${exportId}`); a.download = `table_export_${exportId}.${ext}`; a.click(); } @@ -1366,7 +1367,7 @@ export async function startQueryResultExport(request: QueryResultExportRequest, return new Promise((resolve, reject) => { let started = false; let settled = false; - const eventSource = new EventSource(`/api/export/query-result/progress/${exportId}`); + const eventSource = new EventSource(apiUrl(`/api/export/query-result/progress/${exportId}`)); const finish = (callback: () => void) => { if (settled) return; @@ -1406,7 +1407,7 @@ export async function startQueryResultExport(request: QueryResultExportRequest, function downloadQueryResultExportFile(exportId: string, format: string): void { const a = document.createElement("a"); - a.href = `/api/export/query-result/download/${exportId}`; + a.href = apiUrl(`/api/export/query-result/download/${exportId}`); a.download = `query_result_export_${exportId}.${format}`; a.click(); } diff --git a/apps/desktop/src/lib/httpSqlFileProgress.ts b/apps/desktop/src/lib/httpSqlFileProgress.ts index 722452e38..174751f9e 100644 --- a/apps/desktop/src/lib/httpSqlFileProgress.ts +++ b/apps/desktop/src/lib/httpSqlFileProgress.ts @@ -1,7 +1,8 @@ import type { SqlFileProgress } from "./tauri"; +import { apiUrl } from "@/lib/webPath"; export function listenSqlFileProgressById(executionId: string, handler: (progress: SqlFileProgress) => void): () => void { - const es = new EventSource(`/api/sql-file/progress/${executionId}`); + const es = new EventSource(apiUrl(`/api/sql-file/progress/${executionId}`)); es.onmessage = (e) => { const progress: SqlFileProgress = JSON.parse(e.data); handler(progress); diff --git a/apps/desktop/src/lib/mq-http.ts b/apps/desktop/src/lib/mq-http.ts index 4a503658f..c8af16336 100644 --- a/apps/desktop/src/lib/mq-http.ts +++ b/apps/desktop/src/lib/mq-http.ts @@ -1,4 +1,5 @@ // HTTP fetch API for message queue admin (web mode) +import { apiUrl } from "@/lib/webPath"; import type { MqClusterInfo, TenantInfo, @@ -33,7 +34,7 @@ import type { } from "@/types/mq"; async function post(path: string, body: unknown): Promise { - const resp = await fetch(path, { + const resp = await fetch(apiUrl(path), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), diff --git a/apps/desktop/src/lib/tabResultCache.ts b/apps/desktop/src/lib/tabResultCache.ts index 83f94774e..9ed1594d7 100644 --- a/apps/desktop/src/lib/tabResultCache.ts +++ b/apps/desktop/src/lib/tabResultCache.ts @@ -2,6 +2,7 @@ import type { QueryResult, QueryTab } from "@/types/database"; import { decode, encode } from "@msgpack/msgpack"; import { toRaw } from "vue"; import { isTauriRuntime } from "@/lib/tauriRuntime"; +import { apiUrl } from "@/lib/webPath"; const DB_NAME = "dbx-tab-runtime-cache"; const DB_VERSION = 1; @@ -254,7 +255,7 @@ async function writeRemoteRuntimeCache(key: string, bytes: Uint8Array, stats: { }); return true; } - const response = await fetch("/api/tab-runtime-cache", { + const response = await fetch(apiUrl("/api/tab-runtime-cache"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -279,7 +280,7 @@ async function readRemoteRuntimeCache(key: string): Promise("load_tab_runtime_cache", { key }); return entry?.payloadBase64 ? base64ToBytes(entry.payloadBase64) : undefined; } - const response = await fetch(`/api/tab-runtime-cache?key=${encodeURIComponent(key)}`); + const response = await fetch(apiUrl(`/api/tab-runtime-cache?key=${encodeURIComponent(key)}`)); if (!response.ok) return undefined; const entry = (await response.json()) as { payloadBase64?: string } | null; return entry?.payloadBase64 ? base64ToBytes(entry.payloadBase64) : undefined; @@ -297,7 +298,7 @@ async function deleteRemoteRuntimeCache(key: string): Promise { await invoke("delete_tab_runtime_cache", { key }); return; } - await fetch(`/api/tab-runtime-cache?key=${encodeURIComponent(key)}`, { method: "DELETE" }); + await fetch(apiUrl(`/api/tab-runtime-cache?key=${encodeURIComponent(key)}`), { method: "DELETE" }); } catch (error) { console.warn("[DBX][tab-result-cache:remote-delete:error]", { key, error }); } diff --git a/apps/desktop/src/lib/webPath.ts b/apps/desktop/src/lib/webPath.ts new file mode 100644 index 000000000..24bf6b5b0 --- /dev/null +++ b/apps/desktop/src/lib/webPath.ts @@ -0,0 +1,39 @@ +function normalizeBasePath(value: string | undefined): string { + const trimmed = (value ?? "").trim(); + if (!trimmed || trimmed === "." || trimmed === "./" || trimmed === "/") return ""; + const withoutQuery = trimmed.split(/[?#]/, 1)[0] ?? ""; + const withLeadingSlash = withoutQuery.startsWith("/") ? withoutQuery : `/${withoutQuery}`; + return withLeadingSlash.replace(/\/+$/, ""); +} + +function inferredRuntimeBasePath(pathname: string): string { + const normalized = pathname.replace(/\/+$/, ""); + if (!normalized || normalized === "/login") return ""; + if (normalized.endsWith("/login")) return normalized.slice(0, -"/login".length); + return normalized; +} + +export function dbxWebBasePath(pathname = globalThis.location?.pathname ?? "", buildBase = import.meta.env.BASE_URL): string { + const configured = normalizeBasePath(buildBase); + if (configured) return configured; + return normalizeBasePath(inferredRuntimeBasePath(pathname)); +} + +export function webPath(path: string, basePath = dbxWebBasePath()): string { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const base = normalizeBasePath(basePath); + return `${base}${normalizedPath}` || "/"; +} + +export function apiUrl(path: string, basePath = dbxWebBasePath()): string { + const pathWithLeadingSlash = path.startsWith("/") ? path : `/${path}`; + const normalizedPath = pathWithLeadingSlash === "/api" || pathWithLeadingSlash.startsWith("/api/") || pathWithLeadingSlash.startsWith("/api?") ? pathWithLeadingSlash : `/api${pathWithLeadingSlash}`; + return webPath(normalizedPath, basePath); +} + +type WebSocketLocation = Pick | undefined; + +export function apiWebSocketUrl(path: string, basePath = dbxWebBasePath(), currentLocation: WebSocketLocation = globalThis.location): string { + const protocol = currentLocation?.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${currentLocation?.host ?? ""}${apiUrl(path, basePath)}`; +} diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 6aaf76078..9ae10750a 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -5,6 +5,7 @@ import path from "path"; const host = process.env.TAURI_DEV_HOST; const isTauri = !!host || !!process.env.TAURI_ENV_ARCH; +const configuredBasePath = process.env.VITE_DBX_BASE_PATH || process.env.DBX_PUBLIC_BASE_PATH; const manualChunks: Record = { codemirror: ["codemirror", "@codemirror/lang-sql", "@codemirror/view", "@codemirror/state", "@codemirror/autocomplete", "@codemirror/commands", "@codemirror/theme-one-dark"], "vue-echarts": ["vue-echarts"], @@ -46,8 +47,21 @@ function chunkNameForModule(id: string): string | undefined { return undefined; } +function normalizeViteBase(value: string | undefined): string { + const trimmed = value?.trim(); + if (!trimmed) return "./"; + if (trimmed === "." || trimmed === "./") return "./"; + const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return withLeadingSlash.endsWith("/") ? withLeadingSlash : `${withLeadingSlash}/`; +} + +const viteBase = normalizeViteBase(configuredBasePath); +const publicBasePath = viteBase.startsWith("/") ? viteBase.replace(/\/+$/, "") : ""; +const apiProxyPath = publicBasePath ? `${publicBasePath}/api` : "/api"; + export default defineConfig(async () => ({ root: __dirname, + base: viteBase, plugins: [vue(), tailwindcss()], resolve: { alias: { @@ -76,10 +90,11 @@ export default defineConfig(async () => ({ } : undefined, proxy: { - "/api": { + [apiProxyPath]: { target: "http://localhost:4224", changeOrigin: true, ws: true, + rewrite: publicBasePath ? (requestPath) => requestPath.slice(publicBasePath.length) || "/" : undefined, }, }, watch: { diff --git a/crates/dbx-web/src/auth.rs b/crates/dbx-web/src/auth.rs index b01d5aa12..2d4677cbc 100644 --- a/crates/dbx-web/src/auth.rs +++ b/crates/dbx-web/src/auth.rs @@ -33,6 +33,21 @@ pub struct AuthCheckResponse { const MAX_ATTEMPTS: u32 = 5; const LOCKOUT_SECS: u64 = 60; +fn session_cookie_path(state: &WebState) -> &str { + state.public_base_path.as_str() +} + +fn api_path_suffix<'a>(path: &'a str, public_base_path: &str) -> Option<&'a str> { + if let Some(suffix) = path.strip_prefix("/api/") { + return Some(suffix); + } + let base = public_base_path.trim_end_matches('/'); + if base.is_empty() || base == "/" { + return None; + } + path.strip_prefix(base)?.strip_prefix("/api/") +} + pub async fn login(State(state): State>, Json(body): Json) -> Result { let hash_guard = state.password_hash.read().await; let hash_str = match hash_guard.as_deref() { @@ -80,7 +95,7 @@ pub async fn login(State(state): State>, Json(body): Json>, Json(body): Json>, req: Request(req: &Request) -> Option { @@ -197,13 +212,13 @@ pub async fn auth_middleware( } // Auth endpoints are always accessible - let path = req.uri().path(); - if path.starts_with("/api/auth/") { + let api_suffix = api_path_suffix(req.uri().path(), &state.public_base_path); + if api_suffix.is_some_and(|suffix| suffix.starts_with("auth/")) { return next.run(req).await; } // Non-API requests (static files) are always accessible - if !path.starts_with("/api/") { + if api_suffix.is_none() { return next.run(req).await; } @@ -216,3 +231,22 @@ pub async fn auth_middleware( StatusCode::UNAUTHORIZED.into_response() } + +#[cfg(test)] +mod tests { + use super::api_path_suffix; + + #[test] + fn api_path_suffix_handles_root_api_paths() { + assert_eq!(api_path_suffix("/api/auth/check", "/"), Some("auth/check")); + assert_eq!(api_path_suffix("/api/query/execute", "/"), Some("query/execute")); + assert_eq!(api_path_suffix("/dbx/api/auth/check", "/"), None); + } + + #[test] + fn api_path_suffix_handles_mounted_api_paths() { + assert_eq!(api_path_suffix("/dbx/api/auth/check", "/dbx"), Some("auth/check")); + assert_eq!(api_path_suffix("/tools/dbx/api/query/execute", "/tools/dbx"), Some("query/execute")); + assert_eq!(api_path_suffix("/dbx/login", "/dbx"), None); + } +} diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 236b35c89..24ad8354c 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -32,6 +32,50 @@ fn web_body_limit_bytes() -> usize { mb.saturating_mul(1024 * 1024) } +fn normalize_public_base_path(value: Option) -> String { + let trimmed = value + .unwrap_or_else(|| "/".to_string()) + .split(['?', '#']) + .next() + .unwrap_or("/") + .trim() + .trim_matches('/') + .to_string(); + if trimmed.chars().any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace() || matches!(ch, ';' | ',')) { + panic!("DBX_PUBLIC_BASE_PATH contains invalid characters"); + } + if trimmed.is_empty() { + "/".to_string() + } else { + format!("/{trimmed}") + } +} + +#[cfg(test)] +mod tests { + use super::normalize_public_base_path; + + #[test] + fn normalize_public_base_path_defaults_to_root() { + assert_eq!(normalize_public_base_path(None), "/"); + assert_eq!(normalize_public_base_path(Some("".to_string())), "/"); + assert_eq!(normalize_public_base_path(Some("/".to_string())), "/"); + } + + #[test] + fn normalize_public_base_path_trims_and_preserves_segments() { + assert_eq!(normalize_public_base_path(Some("dbx".to_string())), "/dbx"); + assert_eq!(normalize_public_base_path(Some("/dbx/".to_string())), "/dbx"); + assert_eq!(normalize_public_base_path(Some("/tools/dbx/?v=1".to_string())), "/tools/dbx"); + } + + #[test] + #[should_panic(expected = "DBX_PUBLIC_BASE_PATH contains invalid characters")] + fn normalize_public_base_path_rejects_invalid_characters() { + normalize_public_base_path(Some("/dbx admin".to_string())); + } +} + #[cfg(feature = "mq-admin")] fn add_mq_routes(router: Router>) -> Router> { router @@ -125,9 +169,12 @@ async fn main() { app_state.storage.load_password_hash().await.unwrap_or(None) }; + let public_base_path = normalize_public_base_path(std::env::var("DBX_PUBLIC_BASE_PATH").ok()); + let web_state = Arc::new(WebState { app: app_state, data_dir, + public_base_path: public_base_path.clone(), password_disabled, password_hash: RwLock::new(password_hash), sessions: RwLock::new(HashSet::new()), @@ -481,11 +528,18 @@ async fn main() { app = app.fallback_service(serve_dir); } + if public_base_path != "/" { + app = Router::new().nest(&public_base_path, app); + } + // Bind address let port: u16 = std::env::var("DBX_PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(4224); let addr = SocketAddr::from(([0, 0, 0, 0], port)); tracing::info!("DBX Web server starting on http://{}", addr); + if public_base_path != "/" { + tracing::info!("Serving DBX Web under context path {}", public_base_path); + } if password_disabled { tracing::info!("Password protection is disabled"); } else if std::env::var("DBX_PASSWORD").is_ok() { diff --git a/crates/dbx-web/src/routes/connection.rs b/crates/dbx-web/src/routes/connection.rs index 5a3a8c80a..cf6e8b89b 100644 --- a/crates/dbx-web/src/routes/connection.rs +++ b/crates/dbx-web/src/routes/connection.rs @@ -319,6 +319,7 @@ mod tests { let state = Arc::new(WebState { app, data_dir: dir.clone(), + public_base_path: "/".to_string(), password_disabled: false, password_hash: RwLock::new(None), sessions: RwLock::new(HashSet::new()), diff --git a/crates/dbx-web/src/state.rs b/crates/dbx-web/src/state.rs index 3ae174f3d..19b4ffa70 100644 --- a/crates/dbx-web/src/state.rs +++ b/crates/dbx-web/src/state.rs @@ -13,6 +13,7 @@ pub struct LoginRateLimit { pub struct WebState { pub app: Arc, pub data_dir: PathBuf, + pub public_base_path: String, pub password_disabled: bool, pub password_hash: RwLock>, pub sessions: RwLock>, diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 5177c644c..6134582b8 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -7,6 +7,8 @@ services: - "4224:4224" environment: - DBX_PASSWORD=changeme + # Uncomment when publishing DBX under a reverse-proxy subpath, e.g. https://example.com/dbx/ + # - DBX_PUBLIC_BASE_PATH=/dbx volumes: - dbx-data:/app/data restart: unless-stopped