Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions core/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ COPY ./frontend/package.json ./frontend/pnpm-lock.yaml ./
# Skip test framework browser downloads - not needed for production builds
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
RUN pnpm install
ARG WHODB_BASE_PATH=/
ENV WHODB_BASE_PATH=${WHODB_BASE_PATH}
COPY ./frontend/ ./

# Create empty EE modules for Vite to resolve during CE build
Expand Down Expand Up @@ -83,6 +85,9 @@ RUN BAML_VERSION=$(grep 'github.com/boundaryml/baml ' go.mod | awk '{print $2}'
echo "Downloaded BAML musl library for ${BAML_ARCH}"

FROM alpine:3.23
ARG WHODB_BASE_PATH=/
ENV WHODB_BASE_PATH=${WHODB_BASE_PATH}

RUN apk add --no-cache ca-certificates libgcc
WORKDIR /app
COPY --from=backend-stage /core /core
Expand Down
13 changes: 12 additions & 1 deletion core/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import (
"github.com/clidey/whodb/core/src/plugins"
"github.com/clidey/whodb/core/src/router"
"github.com/clidey/whodb/core/src/settings"
"github.com/go-chi/chi/v5"
chiMiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/pkg/errors"
)

Expand Down Expand Up @@ -72,6 +74,15 @@ func main() {
}

r := router.InitializeRouter(staticFiles)
handler := http.Handler(r)
if env.BasePath != "" && !env.IsDevelopment {
root := chi.NewRouter()
root.Mount(env.BasePath, r)
handler = root
}
// Apply RedirectSlashes at the outermost level so redirects include
// the full path (including any base path prefix from chi.Mount).
handler = chiMiddleware.RedirectSlashes(handler)

port := os.Getenv("PORT")
if port == "" {
Expand All @@ -80,7 +91,7 @@ func main() {

srv := &http.Server{
Addr: fmt.Sprintf(":%s", port),
Handler: r,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 1 * time.Minute,
Expand Down
7 changes: 6 additions & 1 deletion core/src/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/clidey/whodb/core/src/engine"
"github.com/clidey/whodb/core/src/env"
"github.com/clidey/whodb/core/src/log"
"github.com/go-chi/chi/v5"
)

type AuthKey string
Expand Down Expand Up @@ -71,7 +72,11 @@ func isPublicRoute(r *http.Request) bool {
}
}

return !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/api"
routePath := r.URL.Path
if rctx := chi.RouteContext(r.Context()); rctx != nil && rctx.RoutePath != "" {
routePath = rctx.RoutePath
}
return !strings.HasPrefix(routePath, "/api/") && routePath != "/api"
}

func AuthMiddleware(next http.Handler) http.Handler {
Expand Down
14 changes: 14 additions & 0 deletions core/src/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ var IsAWSProviderEnabled = os.Getenv("WHODB_ENABLE_AWS_PROVIDER") == "true"
// DisableCredentialForm controls whether the credential form is disabled.
var DisableCredentialForm = os.Getenv("WHODB_DISABLE_CREDENTIAL_FORM") == "true"

// BasePath is the optional URL prefix (e.g., "/whodb") for sub-path deployments.
var BasePath = normalizeBasePath(os.Getenv("WHODB_BASE_PATH"))

type ChatProvider struct {
Type string
Name string // Display name/alias for the provider
Expand All @@ -94,6 +97,17 @@ type ChatProvider struct {
IsGeneric bool // True for generic/custom providers, false for built-in providers
}

func normalizeBasePath(pathValue string) string {
trimmed := strings.TrimSpace(pathValue)
if trimmed == "" || trimmed == "/" {
return ""
}
if !strings.HasPrefix(trimmed, "/") {
trimmed = "/" + trimmed
}
return strings.TrimSuffix(trimmed, "/")
}

// GenericProviderConfig holds configuration for a generic AI provider.
type GenericProviderConfig struct {
ProviderId string
Expand Down
6 changes: 5 additions & 1 deletion core/src/router/file_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"path"
"strings"

"github.com/clidey/whodb/core/src/env"
"github.com/clidey/whodb/core/src/log"
"github.com/go-chi/chi/v5"
)
Expand Down Expand Up @@ -57,7 +58,10 @@ func fileServer(r chi.Router, staticFiles embed.FS) {
return
}

server := http.FileServer(http.FS(staticFS))
var server http.Handler = http.FileServer(http.FS(staticFS))
if env.BasePath != "" {
server = http.StripPrefix(env.BasePath, server)
}

r.Handle("/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hasExtension(r.URL.Path) {
Expand Down
9 changes: 8 additions & 1 deletion core/src/router/playground.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import (
func setupPlaygroundHandler(router chi.Router, server *handler.Server) {
var pathHandler http.HandlerFunc
if env.IsDevelopment {
pathHandler = playground.Handler("API Gateway", "/api/query")
pathHandler = playground.Handler("API Gateway", graphqlEndpointPath())
}
router.HandleFunc("/api/query", func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" || r.Header.Get("Connection") == "upgrade" {
Expand All @@ -36,3 +36,10 @@ func setupPlaygroundHandler(router chi.Router, server *handler.Server) {
}
})
}

func graphqlEndpointPath() string {
if env.BasePath == "" {
return "/api/query"
}
return env.BasePath + "/api/query"
}
1 change: 0 additions & 1 deletion core/src/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ func setupMiddlewares(router *chi.Mux) {
}

middlewares = append(middlewares,
middleware.RedirectSlashes,
middleware.Recoverer,
middleware.Timeout(90*time.Second), // Increased for LLM inference time
cors.Handler(cors.Options{
Expand Down
57 changes: 57 additions & 0 deletions docs/resources/faq.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,63 @@ Combine multiple security layers for defense in depth
</Tip>
</Accordion>

<Accordion title="How do I run WhoDB under a sub-path (e.g., /whodb)?">
Set `WHODB_BASE_PATH` once — it configures both the backend and the frontend build.

**Docker Compose (build your image with the base path):**
```yaml
version: "3.8"
services:
whodb:
build:
context: .
dockerfile: core/Dockerfile
args:
WHODB_BASE_PATH: /whodb
expose:
- "8080"
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- whodb
```

**Nginx config:**
```nginx
server {
listen 80;
server_name abx.xyz;

location /whodb/ {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://whodb:8080;
}

# Match /whodb without trailing slash
location = /whodb {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://whodb:8080;
}
}
```

<Note>
The frontend bakes the base path at build time. If you change the sub-path, rebuild the image.
</Note>
</Accordion>

<Accordion title="Does WhoDB log my queries?">
WhoDB logging behavior:

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {useCallback, useEffect, useRef, useState} from "react";
import * as desktopService from "../services/desktop";
import {isDesktopApp} from "../utils/external-links";
import {addAuthHeader} from "../utils/auth-headers";
import {withBasePath} from "../config/base-path";


export const useExportToCSV = (schema: string, storageUnit: string, selectedOnly: boolean = false, delimiter: string = ',', selectedRows?: Record<string, any>[], format: 'csv' | 'excel' | 'ndjson' = 'csv') => {
Expand All @@ -44,7 +45,7 @@ export const useExportToCSV = (schema: string, storageUnit: string, selectedOnly

// Use backend export endpoint for full data export
// Add auth header for desktop environments where cookies don't work
const response = await fetch('/api/export', {
const response = await fetch(withBasePath('/api/export'), {
method: 'POST',
credentials: 'include',
headers: addAuthHeader({
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/config/base-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Base path helpers for sub-path deployments.
*/

/**
* Returns the normalized base path for the app.
* Empty string means the app is mounted at the domain root.
*/
export const getBasePath = (): string => {
const rawBase = (import.meta.env.BASE_URL || '/').trim();
if (rawBase === '' || rawBase === '/') {
return '';
}
return rawBase.endsWith('/') ? rawBase.slice(0, -1) : rawBase;
};

/**
* Prefixes an absolute path with the configured base path.
*/
export const withBasePath = (path: string): string => {
const basePath = getBasePath();
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
if (basePath === '') {
return normalizedPath;
}
if (normalizedPath === '/') {
return basePath;
}
return `${basePath}${normalizedPath}`;
};
10 changes: 6 additions & 4 deletions frontend/src/config/graphql-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ import {reduxStore} from '../store';
import {addAuthHeader} from '../utils/auth-headers';
import {isAwsHostname} from '../utils/cloud-connection-prefill';
import {getTranslation, loadTranslations} from '../utils/i18n';
import {withBasePath} from './base-path';

// Always use a relative URI so that:
// Always use a relative URI (with base path) so that:
// - Desktop/Wails uses the embedded router handler
// - Dev server (vite) proxies to the backend via server.proxy in vite.config.ts
const uri = "/api/query";
const uri = withBasePath("/api/query");
const loginPath = withBasePath("/login");
const loginWithProfileQuery = print(LoginWithProfileDocument);
const loginMutationQuery = print(LoginDocument);

Expand All @@ -62,7 +64,7 @@ const redirectToLoginWithMessage = async (
) => {
const t = translator ?? await getTranslator();
toast.error(t(key));
window.location.href = '/login';
window.location.href = loginPath;
};

const httpLink = createHttpLink({
Expand Down Expand Up @@ -100,7 +102,7 @@ const errorLink = onError(({networkError}) => {
void handleAutoLogin(currentProfile);
} else {
// Don't redirect if already on login page to avoid infinite loop
if (!window.location.pathname.startsWith('/login')) {
if (!window.location.pathname.startsWith(loginPath)) {
void redirectToLoginWithMessage('sessionExpired');
}
}
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/config/tour-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ import {
ArrowDownTrayIcon,
} from '../components/heroicons';
import { InternalRoutes } from './routes';
import { withBasePath } from './base-path';

const chatHref = withBasePath(InternalRoutes.Chat.path);
const graphHref = withBasePath(InternalRoutes.Graph.path);
const scratchpadHref = withBasePath(InternalRoutes.RawExecute.path);

export const sampleDatabaseTour: TourConfig = {
id: 'sample-database-tour',
Expand All @@ -40,15 +45,15 @@ export const sampleDatabaseTour: TourConfig = {
path: InternalRoutes.Dashboard.StorageUnit.path,
},
{
target: '[href="/chat"]',
target: `[href="${chatHref}"]`,
title: 'AI Chat Assistant',
description: 'Ask questions in plain English like "Show me all customers" or "What are the top products?". The AI will generate and run SQL queries for you.',
icon: <ChatBubbleLeftRightIcon className="w-6 h-6 text-brand-foreground" />,
position: 'right',
path: InternalRoutes.Dashboard.StorageUnit.path,
},
{
target: '[href="/graph"]',
target: `[href="${graphHref}"]`,
title: 'Visual Schema Explorer',
description: 'See your entire database structure at a glance. Interactive graph shows all tables, columns, and relationships with zoom and pan controls.',
icon: <ShareIcon className="w-6 h-6 text-brand-foreground" />,
Expand All @@ -64,7 +69,7 @@ export const sampleDatabaseTour: TourConfig = {
path: InternalRoutes.Dashboard.StorageUnit.path,
},
{
target: '[href="/scratchpad"]',
target: `[href="${scratchpadHref}"]`,
title: 'SQL Editor & Scratchpad',
description: 'Write custom SQL queries with syntax highlighting and auto-completion. All your queries are automatically saved in history.',
icon: <CodeBracketIcon className="w-6 h-6 text-brand-foreground" />,
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {ThemeProvider} from '@clidey/ux'
import {isEEMode} from './config/ee-imports';
import {isDesktopApp} from './utils/external-links';
import {PosthogConsentBanner} from './components/analytics/posthog-consent-banner';
import {getBasePath} from './config/base-path';

// Detect desktop Linux and add a class for CSS-based overrides (e.g., fonts)
try {
Expand Down Expand Up @@ -88,10 +89,11 @@ const AppWithProviders = () => {
// Use HashRouter for desktop app (avoids full page reloads)
// Use BrowserRouter for web version
const Router = isDesktopApp() ? HashRouter : BrowserRouter;
const routerBasePath = getBasePath() || '/';

root.render(
<React.StrictMode>
<Router>
<Router basename={routerBasePath}>
<ApolloProvider client={graphqlClient}>
<Provider store={reduxStore}>
<PersistGate loading={null} persistor={reduxStorePersistor}>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {useNavigate} from "react-router-dom";
import {useChatExamples} from "./examples";
import {useTranslation} from '@/hooks/use-translation';
import {addAuthHeader, isDesktopScheme} from "../../utils/auth-headers";
import {withBasePath} from '@/config/base-path';

// Lazy load chart components if EE is enabled
const LineChart = isEEFeatureEnabled('dataVisualization') ? loadEEComponent(
Expand Down Expand Up @@ -436,7 +437,7 @@ export const ChatPage: FC = () => {

try {
const isDesktop = isDesktopScheme();
const endpoint = '/api/ai-chat/stream';
const endpoint = withBasePath('/api/ai-chat/stream');

const requestBody = {
schema,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ interface DoryDocsAPI {

interface Window {
DoryDocs?: DoryDocsAPI;
}
}
Loading