feat(web): support context path deployments
This commit is contained in:
parent
45a60819ca
commit
d01ffb0d55
11
README.md
11
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
|
||||
|
|
|
|||
|
|
@ -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 双架构镜像。
|
||||
|
||||
## 快速开始
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue