feat(web): support context path deployments

This commit is contained in:
t8y2 2026-06-29 09:14:37 +08:00
parent 45a60819ca
commit d01ffb0d55
18 changed files with 248 additions and 43 deletions

View File

@ -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

View File

@ -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 双架构镜像。
## 快速开始

View File

@ -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

View File

@ -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" },

View File

@ -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 }),

View File

@ -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");
});
});

View File

@ -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

View File

@ -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<T>(url: string, body: unknown): Promise<T> {
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<T>(url: string, body: unknown): Promise<T> {
}
async function get<T>(url: string): Promise<T> {
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<T>(url: string): Promise<T> {
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<nu
}
const formData = new FormData();
formData.append("file", fileOrPath);
const res = await fetch("/api/agents/import-offline", { method: "POST", body: formData });
const res = await fetch(apiUrl("/api/agents/import-offline"), { method: "POST", body: formData });
if (!res.ok) throw new Error(await res.text());
const result: { count: number } = await res.json();
return result.count;
@ -387,7 +388,7 @@ export async function importAgentJar(dbType: string, pathOrFile: string | File):
const formData = new FormData();
formData.append("dbType", dbType);
formData.append("file", blob, fileName);
const uploadRes = await fetch("/api/agents/import-jar", { method: "POST", body: formData });
const uploadRes = await fetch(apiUrl("/api/agents/import-jar"), { method: "POST", body: formData });
if (!uploadRes.ok) throw new Error(await uploadRes.text());
}
@ -400,7 +401,7 @@ export async function uninstallJre(jreKey: string): Promise<void> {
}
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<string>
}
export async function aiStream(sessionId: string, request: AiCompletionRequest, onChunk: (chunk: AiStreamChunk) => void): Promise<void> {
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<string> {
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<SqlFile
}
const formData = new FormData();
formData.append("file", fileOrPath);
const res = await fetch("/api/sql-file/preview", { method: "POST", body: formData });
const res = await fetch(apiUrl("/api/sql-file/preview"), { method: "POST", body: formData });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
@ -1159,7 +1160,7 @@ export async function writeExternalSqlFile(_path: string, _content: string): Pro
export async function startTransfer(request: TransferRequest, onProgress: (progress: TransferProgress) => void): Promise<void> {
// 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<TableImportSummary> {
// 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<boolean> {
export async function exportDatabaseSql(request: DatabaseExportRequest, onProgress: (progress: ExportProgress) => void): Promise<void> {
// 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();
}

View File

@ -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);

View File

@ -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<T>(path: string, body: unknown): Promise<T> {
const resp = await fetch(path, {
const resp = await fetch(apiUrl(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),

View File

@ -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<Uint8Array | undefin
const entry = await invoke<{ payloadBase64?: string } | null>("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<void> {
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 });
}

View File

@ -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<Location, "protocol" | "host"> | 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)}`;
}

View File

@ -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<string, string[]> = {
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: {

View File

@ -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<Arc<WebState>>, Json(body): Json<LoginRequest>) -> Result<Response, StatusCode> {
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<Arc<WebState>>, Json(body): Json<LoginReq
let token = uuid::Uuid::new_v4().to_string();
state.sessions.write().await.insert(token.clone());
let cookie = format!("dbx_session={token}; Path=/; HttpOnly; SameSite=Lax");
let cookie = format!("dbx_session={token}; Path={}; HttpOnly; SameSite=Lax", session_cookie_path(&state));
Ok((StatusCode::OK, [("set-cookie", cookie.as_str())], Json(serde_json::json!({"ok": true}))).into_response())
}
@ -114,7 +129,7 @@ pub async fn setup(State(state): State<Arc<WebState>>, Json(body): Json<LoginReq
let token = uuid::Uuid::new_v4().to_string();
state.sessions.write().await.insert(token.clone());
let cookie = format!("dbx_session={token}; Path=/; HttpOnly; SameSite=Lax");
let cookie = format!("dbx_session={token}; Path={}; HttpOnly; SameSite=Lax", session_cookie_path(&state));
Ok((StatusCode::OK, [("set-cookie", cookie.as_str())], Json(serde_json::json!({"ok": true}))).into_response())
}
@ -169,8 +184,8 @@ pub async fn logout(State(state): State<Arc<WebState>>, req: Request<axum::body:
if let Some(token) = extract_session_token(&req) {
state.sessions.write().await.remove(&token);
}
let cookie = "dbx_session=; Path=/; HttpOnly; Max-Age=0";
(StatusCode::OK, [("set-cookie", cookie)], Json(serde_json::json!({"ok": true}))).into_response()
let cookie = format!("dbx_session=; Path={}; HttpOnly; Max-Age=0", session_cookie_path(&state));
(StatusCode::OK, [("set-cookie", cookie.as_str())], Json(serde_json::json!({"ok": true}))).into_response()
}
fn extract_session_token<B>(req: &Request<B>) -> Option<String> {
@ -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);
}
}

View File

@ -32,6 +32,50 @@ fn web_body_limit_bytes() -> usize {
mb.saturating_mul(1024 * 1024)
}
fn normalize_public_base_path(value: Option<String>) -> 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<Arc<WebState>>) -> Router<Arc<WebState>> {
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() {

View File

@ -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()),

View File

@ -13,6 +13,7 @@ pub struct LoginRateLimit {
pub struct WebState {
pub app: Arc<AppState>,
pub data_dir: PathBuf,
pub public_base_path: String,
pub password_disabled: bool,
pub password_hash: RwLock<Option<String>>,
pub sessions: RwLock<HashSet<String>>,

View File

@ -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