From 2aaaf87dfd9e8a3d3d2cfd4973860fc25067db1a Mon Sep 17 00:00:00 2001 From: Khairul Hidayat Date: Wed, 19 Mar 2025 05:32:08 +0700 Subject: [PATCH] feat: add base path configuration --- README.md | 5 +- backend/.env.example | 6 +++ backend/main.go | 14 ++++- backend/ui/ui_prod.go | 35 ++++++++++-- index.html | 3 ++ src/app/router.tsx | 78 ++++++++++++++------------- src/components/containers/sidebar.tsx | 3 +- src/global.d.ts | 7 +++ src/lib/api.ts | 7 ++- src/lib/consts.ts | 6 +++ src/lib/utils.ts | 5 ++ 11 files changed, 125 insertions(+), 44 deletions(-) create mode 100644 backend/.env.example create mode 100644 src/global.d.ts create mode 100644 src/lib/consts.ts diff --git a/README.md b/README.md index 95b6ecd..77cdb8b 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ metrics_token = "YOUR_METRICS_TOKEN_HERE" However, if it fails to load, you can set these environment variables instead: - `CONFIG_PATH`: Path to the Garage `config.toml` file. Defaults to `/etc/garage.toml`. +- `BASE_PATH`: Base path or prefix for Web UI. - `API_BASE_URL`: Garage admin API endpoint URL. - `API_ADMIN_KEY`: Admin API key. - `S3_REGION`: S3 Region. @@ -146,7 +147,9 @@ However, if it fails to load, you can set these environment variables instead: ### Authentication -Enable authentication by setting `AUTH_USER_PASS` environment variable. Generate the username and password hash using the following command: +Enable authentication by setting the `AUTH_USER_PASS` environment variable in the format `username:password_hash`, where `password_hash` is a bcrypt hash of the password. + +Generate the username and password hash using the following command: ```bash htpasswd -nbBC 10 "YOUR_USERNAME" "YOUR_PASSWORD" diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..f5fa4af --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,6 @@ +# +BASE_PATH="" +AUTH_USER_PASS='username:$2y$10$DSTi9o0uQPEHSNlf66xMEOgm9KgVNBP3vHxA3SK0Xha2EVMb3mTXm' +API_BASE_URL="http://garage:3903" +S3_ENDPOINT_URL="http://garage:3900" +API_ADMIN_KEY="" diff --git a/backend/main.go b/backend/main.go index 6ae2332..a69ffbe 100644 --- a/backend/main.go +++ b/backend/main.go @@ -7,6 +7,7 @@ import ( "khairul169/garage-webui/utils" "log" "net/http" + "os" "github.com/joho/godotenv" ) @@ -21,10 +22,21 @@ func main() { log.Println("Cannot load garage config!", err) } + basePath := os.Getenv("BASE_PATH") mux := http.NewServeMux() - mux.Handle("/api/", http.StripPrefix("/api", router.HandleApiRouter())) + + // Serve API + apiPrefix := basePath + "/api" + mux.Handle(apiPrefix+"/", http.StripPrefix(apiPrefix, router.HandleApiRouter())) + + // Static files ui.ServeUI(mux) + // Redirect to UI if BASE_PATH is set + if basePath != "" { + mux.Handle("/", http.RedirectHandler(basePath, http.StatusMovedPermanently)) + } + host := utils.GetEnv("HOST", "0.0.0.0") port := utils.GetEnv("PORT", "3909") diff --git a/backend/ui/ui_prod.go b/backend/ui/ui_prod.go index d0f0d52..5a98b84 100644 --- a/backend/ui/ui_prod.go +++ b/backend/ui/ui_prod.go @@ -7,7 +7,10 @@ import ( "embed" "io/fs" "net/http" + "os" "path" + "regexp" + "strings" ) //go:embed dist @@ -16,19 +19,45 @@ var embeddedFs embed.FS func ServeUI(mux *http.ServeMux) { distFs, _ := fs.Sub(embeddedFs, "dist") fileServer := http.FileServer(http.FS(distFs)) + basePath := os.Getenv("BASE_PATH") - mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mux.Handle(basePath+"/", http.StripPrefix(basePath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _path := path.Clean(r.URL.Path)[1:] // Rewrite non-existing paths to index.html if _, err := fs.Stat(distFs, _path); err != nil { index, _ := fs.ReadFile(distFs, "index.html") + html := string(index) + + // Set base path for the UI + html = strings.ReplaceAll(html, "%BASE_PATH%", basePath) + html = addBasePath(html, basePath) + w.Header().Add("Content-Type", "text/html") w.WriteHeader(http.StatusOK) - w.Write(index) + w.Write([]byte(html)) + return + } + + // Add prefix to each /assets strings in js + if len(basePath) > 0 && strings.HasSuffix(_path, ".js") { + data, _ := fs.ReadFile(distFs, _path) + html := string(data) + html = strings.ReplaceAll(html, "assets/", basePath[1:]+"/assets/") + + w.Header().Add("Content-Type", "text/javascript") + w.WriteHeader(http.StatusOK) + w.Write([]byte(html)) return } fileServer.ServeHTTP(w, r) - })) + }))) +} + +func addBasePath(html string, basePath string) string { + re := regexp.MustCompile(`(href|src)=["'](/[^"'>]+)["']`) + return re.ReplaceAllStringFunc(html, func(match string) string { + return re.ReplaceAllString(match, `$1="`+basePath+`$2"`) + }) } diff --git a/index.html b/index.html index fed9617..b6b39cd 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,9 @@ Garage Web UI +
diff --git a/src/app/router.tsx b/src/app/router.tsx index e8b35d9..5911931 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -2,6 +2,7 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { lazy, Suspense } from "react"; import AuthLayout from "@/components/layouts/auth-layout"; import MainLayout from "@/components/layouts/main-layout"; +import { BASE_PATH } from "@/lib/consts"; const LoginPage = lazy(() => import("@/pages/auth/login")); const ClusterPage = lazy(() => import("@/pages/cluster/page")); @@ -10,43 +11,48 @@ const BucketsPage = lazy(() => import("@/pages/buckets/page")); const ManageBucketPage = lazy(() => import("@/pages/buckets/manage/page")); const KeysPage = lazy(() => import("@/pages/keys/page")); -const router = createBrowserRouter([ +const router = createBrowserRouter( + [ + { + path: "/auth", + Component: AuthLayout, + children: [ + { + path: "login", + Component: LoginPage, + }, + ], + }, + { + path: "/", + Component: MainLayout, + children: [ + { + index: true, + Component: HomePage, + }, + { + path: "cluster", + Component: ClusterPage, + }, + { + path: "buckets", + children: [ + { index: true, Component: BucketsPage }, + { path: ":id", Component: ManageBucketPage }, + ], + }, + { + path: "keys", + Component: KeysPage, + }, + ], + }, + ], { - path: "/auth", - Component: AuthLayout, - children: [ - { - path: "login", - Component: LoginPage, - }, - ], - }, - { - path: "/", - Component: MainLayout, - children: [ - { - index: true, - Component: HomePage, - }, - { - path: "cluster", - Component: ClusterPage, - }, - { - path: "buckets", - children: [ - { index: true, Component: BucketsPage }, - { path: ":id", Component: ManageBucketPage }, - ], - }, - { - path: "keys", - Component: KeysPage, - }, - ], - }, -]); + basename: BASE_PATH, + } +); const Router = () => { return ( diff --git a/src/components/containers/sidebar.tsx b/src/components/containers/sidebar.tsx index d196ceb..8e423b3 100644 --- a/src/components/containers/sidebar.tsx +++ b/src/components/containers/sidebar.tsx @@ -15,6 +15,7 @@ import appStore from "@/stores/app-store"; import garageLogo from "@/assets/garage-logo.svg"; import { useMutation } from "@tanstack/react-query"; import api from "@/lib/api"; +import * as utils from "@/lib/utils"; import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; @@ -93,7 +94,7 @@ const LogoutButton = () => { const logout = useMutation({ mutationFn: () => api.post("/auth/logout"), onSuccess: () => { - window.location.href = "/auth/login"; + window.location.href = utils.url("/auth/login"); }, onError: (err) => { toast.error(err?.message || "Unknown error"); diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..8fce500 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,7 @@ +export {}; + +declare global { + interface Window { + __BASE_PATH?: string; + } +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 16e6180..db03c9a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,10 +1,13 @@ +import * as utils from "@/lib/utils"; +import { BASE_PATH } from "./consts"; + type FetchOptions = Omit & { params?: Record; headers?: Record; body?: any; }; -export const API_URL = "/api"; +export const API_URL = BASE_PATH + "/api"; export class APIError extends Error { status!: number; @@ -47,7 +50,7 @@ const api = { const data = isJson ? await res.json() : await res.text(); if (res.status === 401 && !url.startsWith("/auth")) { - window.location.href = "/auth/login"; + window.location.href = utils.url("/auth/login"); throw new APIError("unauthorized", res.status); } diff --git a/src/lib/consts.ts b/src/lib/consts.ts new file mode 100644 index 0000000..3104625 --- /dev/null +++ b/src/lib/consts.ts @@ -0,0 +1,6 @@ +// consts.ts + +export const BASE_PATH = + (import.meta.env.PROD ? window.__BASE_PATH : null) || + import.meta.env.VITE_BASE_PATH || + ""; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ff7a92d..5ebd62c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -3,6 +3,7 @@ import { toast } from "sonner"; import { twMerge } from "tailwind-merge"; import dayjsRelativeTime from "dayjs/plugin/relativeTime"; import dayjs from "dayjs"; +import { BASE_PATH } from "./consts"; dayjs.extend(dayjsRelativeTime); export { dayjs }; @@ -53,3 +54,7 @@ export const copyToClipboard = async (text: string) => { textArea?.remove(); } }; + +export const url = (...paths: unknown[]) => { + return BASE_PATH + paths.join("/"); +};