feature: support serving from subpath (#33)

Co-authored-by: Karthik T <karthikt.holmes+github@gmail.com>
This commit is contained in:
Henry Dollman
2025-02-04 21:22:40 -05:00
parent ce171cf375
commit 58085bf300
17 changed files with 118 additions and 54 deletions

View File

@@ -12,6 +12,7 @@ import (
"crypto/ed25519"
"encoding/pem"
"fmt"
"io/fs"
"log"
"net"
"net/http"
@@ -40,15 +41,18 @@ type Hub struct {
rm *records.RecordManager
systemStats *core.Collection
containerStats *core.Collection
appURL string
}
func NewHub(app *pocketbase.PocketBase) *Hub {
return &Hub{
hub := &Hub{
app: app,
am: alerts.NewAlertManager(app),
um: users.NewUserManager(app),
rm: records.NewRecordManager(app),
}
hub.appURL, _ = GetEnv("APP_URL")
return hub
}
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
@@ -82,6 +86,10 @@ func (h *Hub) Run() {
settings := h.app.Settings()
// batch requests (for global alerts)
settings.Batch.Enabled = true
// set URL if BASE_URL env is set
if h.appURL != "" {
settings.Meta.AppURL = h.appURL
}
// set auth settings
usersCollection, err := h.app.FindCollectionByNameOrId("users")
if err != nil {
@@ -118,19 +126,39 @@ func (h *Hub) Run() {
Scheme: "http",
Host: "localhost:5173",
})
se.Router.Any("/", func(e *core.RequestEvent) error {
se.Router.Any("/{path...}", func(e *core.RequestEvent) error {
proxy.ServeHTTP(e.Response, e.Request)
return nil
})
default:
// parse app url
parsedURL, err := url.Parse(h.appURL)
if err != nil {
return err
}
// fix base paths in html if using subpath
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
indexContent := strings.ReplaceAll(string(indexFile), "./", basePath)
// set up static asset serving
staticPaths := [2]string{"/static/", "/assets/"}
serveStatic := apis.Static(site.DistDirFS, false)
// get CSP configuration
csp, cspExists := GetEnv("CSP")
s := apis.Static(site.DistDirFS, true)
// add route
se.Router.Any("/{path...}", func(e *core.RequestEvent) error {
// serve static assets if path is in staticPaths
for i := range staticPaths {
if strings.Contains(e.Request.URL.Path, staticPaths[i]) {
e.Response.Header().Set("Cache-Control", "public, max-age=2592000")
return serveStatic(e)
}
}
if cspExists {
e.Response.Header().Del("X-Frame-Options")
e.Response.Header().Set("Content-Security-Policy", csp)
}
return s(e)
return e.HTML(http.StatusOK, indexContent)
})
}
return se.Next()

View File

@@ -1,10 +1,11 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Beszel</title>
<script>window.BASE_PATH = "%BASE_URL%"</script>
</head>
<body>
<div id="app"></div>

View File

@@ -18,7 +18,7 @@ import { Copy, PlusIcon } from "lucide-react"
import { useState, useRef, MutableRefObject } from "react"
import { useStore } from "@nanostores/react"
import { cn, copyToClipboard, isReadOnlyUser } from "@/lib/utils"
import { navigate } from "./router"
import { basePath, navigate } from "./router"
import { Trans } from "@lingui/macro"
import { i18n } from "@lingui/core"
@@ -60,7 +60,7 @@ export function AddSystemButton({ className }: { className?: string }) {
try {
setOpen(false)
await pb.collection("systems").create(data)
navigate("/")
navigate(basePath)
// console.log(record)
} catch (e) {
console.log(e)

View File

@@ -23,8 +23,9 @@ import { useEffect } from "react"
import { useStore } from "@nanostores/react"
import { $systems } from "@/lib/stores"
import { isAdmin } from "@/lib/utils"
import { navigate } from "./router"
import { $router, basePath, navigate } from "./router"
import { Trans, t } from "@lingui/macro"
import { getPagePath } from "@nanostores/router"
export default function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
const systems = useStore($systems)
@@ -55,7 +56,7 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
<CommandItem
key={system.id}
onSelect={() => {
navigate(`/system/${encodeURIComponent(system.name)}`)
navigate(getPagePath($router, "system", { name: system.name }))
setOpen(false)
}}
>
@@ -72,7 +73,7 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
<CommandItem
keywords={["home"]}
onSelect={() => {
navigate("/")
navigate(basePath)
setOpen(false)
}}
>
@@ -86,7 +87,7 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
</CommandItem>
<CommandItem
onSelect={() => {
navigate("/settings/general")
navigate(getPagePath($router, "settings", { name: "general" }))
setOpen(false)
}}
>
@@ -101,7 +102,7 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
<CommandItem
keywords={["alerts"]}
onSelect={() => {
navigate("/settings/notifications")
navigate(getPagePath($router, "settings", { name: "notifications" }))
setOpen(false)
}}
>

View File

@@ -9,8 +9,9 @@ import { toast } from "../ui/use-toast"
import { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { useCallback, useState } from "react"
import { AuthMethodsList, OAuth2AuthConfig } from "pocketbase"
import { Link } from "../router"
import { $router, Link, prependBasePath } from "../router"
import { Trans, t } from "@lingui/macro"
import { getPagePath } from "@nanostores/router"
const honeypot = v.literal("")
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
@@ -260,11 +261,11 @@ export function UserAuthForm({
) : (
<img
className="me-2 h-4 w-4 dark:brightness-0 dark:invert"
src={`/_/images/oauth2/${provider.name}.svg`}
src={prependBasePath(`/_/images/oauth2/${provider.name}.svg`)}
alt=""
onError={(e) => {
e.currentTarget.src = "/static/lock.svg"
}}
// onError={(e) => {
// e.currentTarget.src = "/static/lock.svg"
// }}
/>
)}
<span className="translate-y-[1px]">{provider.displayName}</span>
@@ -278,7 +279,7 @@ export function UserAuthForm({
<Dialog>
<DialogTrigger asChild>
<button type="button" className={cn(buttonVariants({ variant: "outline" }))}>
<img className="me-2 h-4 w-4 dark:invert" src="/_/images/oauth2/github.svg" alt="" />
<img className="me-2 h-4 w-4 dark:invert" src={prependBasePath("/_/images/oauth2/github.svg")} alt="" />
<span className="translate-y-[1px]">GitHub</span>
</button>
</DialogTrigger>
@@ -311,7 +312,7 @@ export function UserAuthForm({
{passwordEnabled && !isFirstRun && (
<Link
href="/forgot-password"
href={getPagePath($router, "forgot_password")}
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
>
<Trans>Forgot password?</Trans>

View File

@@ -34,7 +34,7 @@ export default function () {
const subtitle = useMemo(() => {
if (isFirstRun) {
return t`Please create an admin account`
} else if (page?.path === "/forgot-password") {
} else if (page?.route === "forgot_password") {
return t`Enter email address to reset password`
} else {
return t`Please sign in to your account`
@@ -59,7 +59,7 @@ export default function () {
</h1>
<p className="text-sm text-muted-foreground">{subtitle}</p>
</div>
{page?.path === "/forgot-password" ? (
{page?.route === "forgot_password" ? (
<ForgotPassword />
) : (
<UserAuthForm isFirstRun={isFirstRun} authMethods={authMethods} />

View File

@@ -10,7 +10,7 @@ import {
UserIcon,
UsersIcon,
} from "lucide-react"
import { Link } from "./router"
import { $router, basePath, Link, prependBasePath } from "./router"
import { LangToggle } from "./lang-toggle"
import { ModeToggle } from "./mode-toggle"
import { Logo } from "./logo"
@@ -27,6 +27,7 @@ import {
} from "@/components/ui/dropdown-menu"
import { AddSystemButton } from "./add-system"
import { Trans } from "@lingui/macro"
import { getPagePath } from "@nanostores/router"
const CommandPalette = lazy(() => import("./command-palette"))
@@ -35,7 +36,7 @@ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
export default function Navbar() {
return (
<div className="flex items-center h-14 md:h-16 bg-card px-4 pe-3 sm:px-6 border border-border/60 bt-0 rounded-md my-4">
<Link href="/" aria-label="Home" className="p-2 ps-0 me-3">
<Link href={basePath} aria-label="Home" className="p-2 ps-0 me-3">
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
</Link>
<SearchButton />
@@ -44,7 +45,7 @@ export default function Navbar() {
<LangToggle />
<ModeToggle />
<Link
href="/settings/general"
href={getPagePath($router, "settings", { name: "general" })}
aria-label="Settings"
className={cn("", buttonVariants({ variant: "ghost", size: "icon" }))}
>
@@ -63,7 +64,7 @@ export default function Navbar() {
{isAdmin() && (
<>
<DropdownMenuItem asChild>
<a href="/_/" target="_blank">
<a href={prependBasePath("/_/")} target="_blank">
<UsersIcon className="me-2.5 h-4 w-4" />
<span>
<Trans>Users</Trans>
@@ -71,7 +72,7 @@ export default function Navbar() {
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="/_/#/collections?collection=systems" target="_blank">
<a href={prependBasePath("/_/#/collections?collection=systems")} target="_blank">
<ServerIcon className="me-2.5 h-4 w-4" />
<span>
<Trans>Systems</Trans>
@@ -79,7 +80,7 @@ export default function Navbar() {
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="/_/#/logs" target="_blank">
<a href={prependBasePath("/_/#/logs")} target="_blank">
<LogsIcon className="me-2.5 h-4 w-4" />
<span>
<Trans>Logs</Trans>
@@ -87,7 +88,7 @@ export default function Navbar() {
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="/_/#/settings/backups" target="_blank">
<a href={prependBasePath("/_/#/settings/backups")} target="_blank">
<DatabaseBackupIcon className="me-2.5 h-4 w-4" />
<span>
<Trans>Backups</Trans>

View File

@@ -1,16 +1,36 @@
import { createRouter } from "@nanostores/router"
export const $router = createRouter(
{
home: "/",
server: "/system/:name",
settings: "/settings/:name?",
forgot_password: "/forgot-password",
},
{ links: false }
)
const routes = {
home: "/",
system: `/system/:name`,
settings: `/settings/:name?`,
forgot_password: `/forgot-password`,
} as const
/** Navigate to url using router */
/**
* The base path of the application.
* This is used to prepend the base path to all routes.
*/
export const basePath = window.BASE_PATH || ""
/**
* Prepends the base path to the given path.
* @param path The path to prepend the base path to.
* @returns The path with the base path prepended.
*/
export const prependBasePath = (path: string) => (basePath + path).replaceAll("//", "/")
// prepend base path to routes
for (const route in routes) {
// @ts-ignore need as const above to get nanostores to parse types properly
routes[route] = prependBasePath(routes[route])
}
export const $router = createRouter(routes, { links: false })
/** Navigate to url using router
* Base path is automatically prepended if serving from subpath
*/
export const navigate = (urlString: string) => {
$router.open(urlString)
}

View File

@@ -7,8 +7,9 @@ import { Separator } from "../ui/separator"
import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
import { AlertRecord, SystemRecord } from "@/types"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Link } from "../router"
import { $router, Link } from "../router"
import { Plural, t, Trans } from "@lingui/macro"
import { getPagePath } from "@nanostores/router"
const SystemsTable = lazy(() => import("../systems-table/systems-table"))
@@ -83,7 +84,7 @@ export default function Home() {
</Trans>
</AlertDescription>
<Link
href={`/system/${encodeURIComponent(alert.sysname!)}`}
href={getPagePath($router, "system", { name: alert.sysname! })}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>

View File

@@ -4,7 +4,7 @@ import { SidebarNav } from "./sidebar-nav.tsx"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import { useStore } from "@nanostores/react"
import { $router } from "@/components/router.tsx"
import { redirectPage } from "@nanostores/router"
import { getPagePath, redirectPage } from "@nanostores/router"
import { BellIcon, FileSlidersIcon, SettingsIcon } from "lucide-react"
import { $userSettings, pb } from "@/lib/stores.ts"
import { toast } from "@/components/ui/use-toast.ts"
@@ -49,17 +49,17 @@ export default function SettingsLayout() {
const sidebarNavItems = [
{
title: _(t({ message: `General`, comment: "Context: General settings" })),
href: "/settings/general",
href: getPagePath($router, "settings", { name: "general" }),
icon: SettingsIcon,
},
{
title: t`Notifications`,
href: "/settings/notifications",
href: getPagePath($router, "settings", { name: "notifications" }),
icon: BellIcon,
},
{
title: t`YAML Config`,
href: "/settings/config",
href: getPagePath($router, "settings", { name: "config" }),
icon: FileSlidersIcon,
admin: true,
},
@@ -69,8 +69,8 @@ export default function SettingsLayout() {
useEffect(() => {
document.title = t`Settings` + " / Beszel"
// redirect to account page if no page is specified
if (page?.path === "/settings") {
// @ts-ignore redirect to account page if no page is specified
if (!page?.params?.name) {
redirectPage($router, "settings", { name: "general" })
}
}, [])

View File

@@ -13,6 +13,7 @@ import { saveSettings } from "./layout"
import * as v from "valibot"
import { isAdmin } from "@/lib/utils"
import { Trans, t } from "@lingui/macro"
import { prependBasePath } from "@/components/router"
interface ShoutrrrUrlCardProps {
url: string
@@ -94,7 +95,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
Please{" "}
<a href="/_/#/settings/mail" className="link" target="_blank">
<a href={prependBasePath("/_/#/settings/mail")} className="link" target="_blank">
configure an SMTP server
</a>{" "}
to ensure alerts are delivered.

View File

@@ -22,7 +22,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
<>
{/* Mobile View */}
<div className="md:hidden">
<Select onValueChange={(value: string) => navigate(value)} value={page?.path}>
<Select onValueChange={navigate} value={page?.path}>
<SelectTrigger className="w-full my-3.5">
<SelectValue placeholder="Select page" />
</SelectTrigger>

View File

@@ -65,13 +65,14 @@ import { $hubVersion, $systems, pb } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
import AlertsButton from "../alerts/alert-button"
import { Link, navigate } from "../router"
import { $router, Link, navigate } from "../router"
import { EthernetIcon } from "../ui/icons"
import { Trans, t } from "@lingui/macro"
import { useLingui } from "@lingui/react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
import { Input } from "../ui/input"
import { ClassValue } from "clsx"
import { getPagePath } from "@nanostores/router"
type ViewMode = "table" | "grid"
@@ -406,7 +407,7 @@ export default function SystemsTable() {
onClick={(e) => {
const target = e.target as HTMLElement
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
navigate(`/system/${encodeURIComponent(row.original.name)}`)
navigate(getPagePath($router, "system", { name: row.original.name }))
}
}}
>
@@ -489,7 +490,7 @@ export default function SystemsTable() {
})}
</CardContent>
<Link
href={`/system/${encodeURIComponent(row.original.name)}`}
href={getPagePath($router, "system", { name: row.original.name })}
className="inset-0 absolute w-full h-full"
>
<span className="sr-only">{row.original.name}</span>

View File

@@ -1,9 +1,10 @@
import PocketBase from "pocketbase"
import { atom, map, WritableAtom } from "nanostores"
import { AlertRecord, ChartTimes, SystemRecord, UserSettings } from "@/types"
import { basePath } from "@/components/router"
/** PocketBase JS Client */
export const pb = new PocketBase("/")
export const pb = new PocketBase(basePath)
/** Store if user is authenticated */
export const $authenticated = atom(pb.authStore.isValid)

View File

@@ -63,9 +63,9 @@ const App = () => {
if (!page) {
return <h1 className="text-3xl text-center my-14">404</h1>
} else if (page.path === "/") {
} else if (page.route === "home") {
return <Home />
} else if (page.route === "server") {
} else if (page.route === "system") {
return <SystemDetail name={page.params.name} />
} else if (page.route === "settings") {
return (

View File

@@ -1,5 +1,12 @@
import { RecordModel } from "pocketbase"
// global window properties
declare global {
interface Window {
BASE_PATH: string
}
}
export interface SystemRecord extends RecordModel {
name: string
host: string

View File

@@ -4,6 +4,7 @@ import react from "@vitejs/plugin-react-swc"
import { lingui } from "@lingui/vite-plugin"
export default defineConfig({
base: "./",
plugins: [
react({
plugins: [["@lingui/swc-plugin", {}]],