+

+
+
+ UFO Backup
+
+
Formerly PG Back Web
+
+ Effortless backups with a user-friendly web interface!
+
+
+
+ Currently supporting: PostgreSQL
+
+
+
+ Connect, share, and collaborate with us below.
+
+
+
+
+
+
diff --git a/internal/config/env.go b/internal/config/env.go
index b4067ee7..31d39852 100644
--- a/internal/config/env.go
+++ b/internal/config/env.go
@@ -12,6 +12,7 @@ type Env struct {
PBW_POSTGRES_CONN_STRING string `env:"PBW_POSTGRES_CONN_STRING,required"`
PBW_LISTEN_HOST string `env:"PBW_LISTEN_HOST" envDefault:"0.0.0.0"`
PBW_LISTEN_PORT string `env:"PBW_LISTEN_PORT" envDefault:"8085"`
+ PBW_PATH_PREFIX string `env:"PBW_PATH_PREFIX" envDefault:""`
}
var (
diff --git a/internal/config/env_validate.go b/internal/config/env_validate.go
index b538a8b1..07aa366d 100644
--- a/internal/config/env_validate.go
+++ b/internal/config/env_validate.go
@@ -16,5 +16,9 @@ func validateEnv(env Env) error {
return fmt.Errorf("invalid listen port %s, valid values are 1-65535", env.PBW_LISTEN_PORT)
}
+ if !validate.PathPrefix(env.PBW_PATH_PREFIX) {
+ return fmt.Errorf("invalid path prefix %s, must start with / and not end with / (or be empty)", env.PBW_PATH_PREFIX)
+ }
+
return nil
}
diff --git a/internal/util/pathutil/pathutil.go b/internal/util/pathutil/pathutil.go
new file mode 100644
index 00000000..d67e7779
--- /dev/null
+++ b/internal/util/pathutil/pathutil.go
@@ -0,0 +1,35 @@
+package pathutil
+
+import "sync"
+
+var (
+ pathPrefix string
+ pathPrefixOnce sync.Once
+)
+
+// SetPathPrefix sets the path prefix once. This should be called during
+// application initialization with the value from the environment config.
+func SetPathPrefix(prefix string) {
+ pathPrefixOnce.Do(func() {
+ pathPrefix = prefix
+ })
+}
+
+// GetPathPrefix returns the configured path prefix.
+func GetPathPrefix() string {
+ return pathPrefix
+}
+
+// BuildPath constructs a full path by prepending the configured path prefix
+// to the given path. If no prefix is configured, returns the path as-is.
+//
+// Examples:
+// - BuildPath("/dashboard") with prefix "/pgbackweb" -> "/pgbackweb/dashboard"
+// - BuildPath("/dashboard") with no prefix -> "/dashboard"
+// - BuildPath("") with prefix "/pgbackweb" -> "/pgbackweb"
+func BuildPath(path string) string {
+ if pathPrefix == "" {
+ return path
+ }
+ return pathPrefix + path
+}
diff --git a/internal/util/pathutil/pathutil_test.go b/internal/util/pathutil/pathutil_test.go
new file mode 100644
index 00000000..30a35a05
--- /dev/null
+++ b/internal/util/pathutil/pathutil_test.go
@@ -0,0 +1,87 @@
+package pathutil
+
+import (
+ "sync"
+ "testing"
+)
+
+func TestBuildPath(t *testing.T) {
+ t.Helper()
+
+ tests := []struct {
+ name string
+ prefix string
+ path string
+ expected string
+ }{
+ {
+ name: "no prefix configured",
+ prefix: "",
+ path: "/dashboard",
+ expected: "/dashboard",
+ },
+ {
+ name: "with prefix - dashboard",
+ prefix: "/pgbackweb",
+ path: "/dashboard",
+ expected: "/pgbackweb/dashboard",
+ },
+ {
+ name: "with prefix - api",
+ prefix: "/pgbackweb",
+ path: "/api/v1/health",
+ expected: "/pgbackweb/api/v1/health",
+ },
+ {
+ name: "with prefix - root",
+ prefix: "/pgbackweb",
+ path: "",
+ expected: "/pgbackweb",
+ },
+ {
+ name: "with prefix - auth",
+ prefix: "/pgbackweb",
+ path: "/auth/login",
+ expected: "/pgbackweb/auth/login",
+ },
+ {
+ name: "empty prefix and empty path",
+ prefix: "",
+ path: "",
+ expected: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ originalPrefix := pathPrefix
+ pathPrefixOnce = sync.Once{}
+ SetPathPrefix(tt.prefix)
+ defer func() {
+ pathPrefixOnce = sync.Once{}
+ pathPrefix = originalPrefix
+ }()
+
+ result := BuildPath(tt.path)
+ if result != tt.expected {
+ t.Errorf("BuildPath(%q) with prefix %q = %q, want %q", tt.path, tt.prefix, result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestGetPathPrefix(t *testing.T) {
+ t.Helper()
+ originalPrefix := pathPrefix
+ pathPrefixOnce = sync.Once{}
+ SetPathPrefix("/test-prefix")
+ defer func() {
+ pathPrefixOnce = sync.Once{}
+ pathPrefix = originalPrefix
+ }()
+
+ result := GetPathPrefix()
+ if result != "/test-prefix" {
+ t.Errorf("GetPathPrefix() = %q, want %q", result, "/test-prefix")
+ }
+}
diff --git a/internal/validate/path_prefix.go b/internal/validate/path_prefix.go
new file mode 100644
index 00000000..0d4ff44a
--- /dev/null
+++ b/internal/validate/path_prefix.go
@@ -0,0 +1,43 @@
+package validate
+
+import "strings"
+
+// PathPrefix validates that a path prefix is correctly formatted.
+//
+// Valid path prefixes:
+// - Empty string (no prefix)
+// - Must start with /
+// - Must NOT end with /
+// - No whitespace allowed
+//
+// Examples:
+// - "" -> true (no prefix)
+// - "/api" -> true
+// - "/pgbackweb" -> true
+// - "/app/v1" -> true
+// - "api" -> false (doesn't start with /)
+// - "/api/" -> false (ends with /)
+// - "/ api" -> false (contains whitespace)
+func PathPrefix(pathPrefix string) bool {
+ // Empty string is valid (no prefix)
+ if pathPrefix == "" {
+ return true
+ }
+
+ // Must start with /
+ if !strings.HasPrefix(pathPrefix, "/") {
+ return false
+ }
+
+ // Must NOT end with /
+ if strings.HasSuffix(pathPrefix, "/") {
+ return false
+ }
+
+ // No whitespace allowed
+ if strings.ContainsAny(pathPrefix, " \t\n\r") {
+ return false
+ }
+
+ return true
+}
diff --git a/internal/validate/path_prefix_test.go b/internal/validate/path_prefix_test.go
new file mode 100644
index 00000000..a770baf2
--- /dev/null
+++ b/internal/validate/path_prefix_test.go
@@ -0,0 +1,78 @@
+package validate
+
+import "testing"
+
+func TestPathPrefix(t *testing.T) {
+ t.Helper()
+
+ tests := []struct {
+ name string
+ input string
+ expected bool
+ }{
+ {
+ name: "empty string is valid",
+ input: "",
+ expected: true,
+ },
+ {
+ name: "valid simple path",
+ input: "/api",
+ expected: true,
+ },
+ {
+ name: "valid complex path",
+ input: "/pgbackweb",
+ expected: true,
+ },
+ {
+ name: "valid nested path",
+ input: "/app/v1",
+ expected: true,
+ },
+ {
+ name: "valid deep nested path",
+ input: "/api/app/v1",
+ expected: true,
+ },
+ {
+ name: "invalid - doesn't start with slash",
+ input: "api",
+ expected: false,
+ },
+ {
+ name: "invalid - ends with slash",
+ input: "/api/",
+ expected: false,
+ },
+ {
+ name: "invalid - only slash",
+ input: "/",
+ expected: false,
+ },
+ {
+ name: "invalid - contains space",
+ input: "/api path",
+ expected: false,
+ },
+ {
+ name: "invalid - contains tab",
+ input: "/api\tpath",
+ expected: false,
+ },
+ {
+ name: "invalid - contains newline",
+ input: "/api\npath",
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := PathPrefix(tt.input)
+ if result != tt.expected {
+ t.Errorf("PathPrefix(%q) = %v, want %v", tt.input, result, tt.expected)
+ }
+ })
+ }
+}
diff --git a/internal/view/middleware/require_auth.go b/internal/view/middleware/require_auth.go
index 8541211b..f3fd2082 100644
--- a/internal/view/middleware/require_auth.go
+++ b/internal/view/middleware/require_auth.go
@@ -4,6 +4,7 @@ import (
"net/http"
"github.com/eduardolat/pgbackweb/internal/logger"
+ "github.com/eduardolat/pgbackweb/internal/util/pathutil"
"github.com/eduardolat/pgbackweb/internal/view/reqctx"
"github.com/labstack/echo/v4"
htmx "github.com/nodxdev/nodxgo-htmx"
@@ -29,11 +30,13 @@ func (m *Middleware) RequireAuth(next echo.HandlerFunc) echo.HandlerFunc {
}
if usersQty == 0 {
- htmx.ServerSetRedirect(c.Response().Header(), "/auth/create-first-user")
- return c.Redirect(http.StatusFound, "/auth/create-first-user")
+ redirectPath := pathutil.BuildPath("/auth/create-first-user")
+ htmx.ServerSetRedirect(c.Response().Header(), redirectPath)
+ return c.Redirect(http.StatusFound, redirectPath)
}
- htmx.ServerSetRedirect(c.Response().Header(), "/auth/login")
- return c.Redirect(http.StatusFound, "/auth/login")
+ redirectPath := pathutil.BuildPath("/auth/login")
+ htmx.ServerSetRedirect(c.Response().Header(), redirectPath)
+ return c.Redirect(http.StatusFound, redirectPath)
}
}
diff --git a/internal/view/middleware/require_no_auth.go b/internal/view/middleware/require_no_auth.go
index 03c4a5b6..c9a57e0f 100644
--- a/internal/view/middleware/require_no_auth.go
+++ b/internal/view/middleware/require_no_auth.go
@@ -3,6 +3,7 @@ package middleware
import (
"net/http"
+ "github.com/eduardolat/pgbackweb/internal/util/pathutil"
"github.com/eduardolat/pgbackweb/internal/view/reqctx"
"github.com/labstack/echo/v4"
htmx "github.com/nodxdev/nodxgo-htmx"
@@ -13,8 +14,9 @@ func (m *Middleware) RequireNoAuth(next echo.HandlerFunc) echo.HandlerFunc {
reqCtx := reqctx.GetCtx(c)
if reqCtx.IsAuthed {
- htmx.ServerSetRedirect(c.Response().Header(), "/dashboard")
- return c.Redirect(http.StatusFound, "/dashboard")
+ redirectPath := pathutil.BuildPath("/dashboard")
+ htmx.ServerSetRedirect(c.Response().Header(), redirectPath)
+ return c.Redirect(http.StatusFound, redirectPath)
}
return next(c)
diff --git a/internal/view/router.go b/internal/view/router.go
index 730dda4e..9af5d15a 100644
--- a/internal/view/router.go
+++ b/internal/view/router.go
@@ -1,9 +1,12 @@
package view
import (
+ "io/fs"
"time"
+ "github.com/eduardolat/pgbackweb/internal/logger"
"github.com/eduardolat/pgbackweb/internal/service"
+ "github.com/eduardolat/pgbackweb/internal/util/pathutil"
"github.com/eduardolat/pgbackweb/internal/view/api"
"github.com/eduardolat/pgbackweb/internal/view/middleware"
"github.com/eduardolat/pgbackweb/internal/view/static"
@@ -14,17 +17,28 @@ import (
func MountRouter(app *echo.Echo, servs *service.Service) {
mids := middleware.New(servs)
+ // Create the base group with the path prefix (if any)
+ baseGroup := app.Group(pathutil.GetPathPrefix())
+
browserCache := mids.NewBrowserCacheMiddleware(
middleware.BrowserCacheMiddlewareConfig{
CacheDuration: time.Hour * 24 * 30,
ExcludedFiles: []string{"/robots.txt"},
},
)
- app.Group("", browserCache).StaticFS("", static.StaticFs)
- apiGroup := app.Group("/api")
+ // Mount static files
+ staticFS, err := fs.Sub(static.StaticFs, ".")
+ if err != nil {
+ logger.FatalError("failed to create static filesystem", logger.KV{"error": err})
+ }
+
+ staticGroup := baseGroup.Group("", browserCache)
+ staticGroup.StaticFS("/", staticFS)
+
+ apiGroup := baseGroup.Group("/api")
api.MountRouter(apiGroup, mids, servs)
- webGroup := app.Group("", mids.InjectReqctx)
+ webGroup := baseGroup.Group("", mids.InjectReqctx)
web.MountRouter(webGroup, mids, servs)
}
diff --git a/internal/view/static/css/partials/sweetalert2.css b/internal/view/static/css/partials/sweetalert2.css
deleted file mode 100644
index d70426cb..00000000
--- a/internal/view/static/css/partials/sweetalert2.css
+++ /dev/null
@@ -1,9 +0,0 @@
-/*
- Fix sweetalert2 scroll issue
- https://github.com/sweetalert2/sweetalert2/issues/781#issuecomment-475108658
-*/
-body.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown),
-html.swal2-shown:not(.swal2-no-backdrop):not(.swal2-toast-shown) {
- height: 100% !important;
- overflow-y: visible !important;
-}
diff --git a/internal/view/static/css/style.css b/internal/view/static/css/style.css
index 6832e346..4687a8a8 100644
--- a/internal/view/static/css/style.css
+++ b/internal/view/static/css/style.css
@@ -6,4 +6,3 @@
@import "./partials/slim-select.css";
@import "./partials/notyf.css";
@import "./partials/scrollbar.css";
-@import "./partials/sweetalert2.css";
diff --git a/internal/view/static/js/app.js b/internal/view/static/js/app.js
index e7c2b60c..435cf67a 100644
--- a/internal/view/static/js/app.js
+++ b/internal/view/static/js/app.js
@@ -1,11 +1,11 @@
import { initThemeHelper } from "./init-theme-helper.js";
-import { initSweetAlert2 } from "./init-sweetalert2.js";
+import { initDialogs } from "./init-dialogs.js";
import { initNotyf } from "./init-notyf.js";
import { initHTMX } from "./init-htmx.js";
import { initHelpers } from "./init-helpers.js";
initThemeHelper();
-initSweetAlert2();
+initDialogs();
initNotyf();
initHTMX();
initHelpers();
diff --git a/internal/view/static/js/init-dialogs.js b/internal/view/static/js/init-dialogs.js
new file mode 100644
index 00000000..c11fbc49
--- /dev/null
+++ b/internal/view/static/js/init-dialogs.js
@@ -0,0 +1,137 @@
+export function initDialogs() {
+ /**
+ * Shows an alert dialog
+ * @param {string} text - The text to display
+ * @returns {Promise<{isConfirmed: boolean, isDismissed: boolean}>}
+ */
+ async function customAlert(text) {
+ return showDialog(text, false);
+ }
+
+ /**
+ * Shows a confirmation dialog
+ * @param {string} text - The text to display
+ * @returns {Promise<{isConfirmed: boolean, isDismissed: boolean}>}
+ */
+ async function customConfirm(text) {
+ return showDialog(text, true);
+ }
+
+ /**
+ * Shows a dialog
+ * @param {string} text - The text to display
+ * @param {boolean} isConfirm - True for confirm dialog, false for alert
+ */
+ function showDialog(text, isConfirm) {
+ return new Promise((resolve) => {
+ const dialogId = "dialog-" + Date.now();
+ const container = createDialog(dialogId, text, isConfirm, resolve);
+ document.body.appendChild(container);
+
+ // Fade in
+ requestAnimationFrame(() => {
+ container.style.opacity = "0";
+ container.classList.remove("hidden");
+ requestAnimationFrame(() => {
+ container.style.transition = "opacity 0.15s ease-in-out";
+ container.style.opacity = "1";
+ });
+ });
+ });
+ }
+
+ /**
+ * Creates the dialog HTML
+ */
+ function createDialog(dialogId, text, isConfirm, resolve) {
+ // Container
+ const container = document.createElement("div");
+ container.id = dialogId;
+ container.className =
+ "hidden !p-0 !m-0 w-[100dvw] h-[100dvh] fixed left-0 top-0 z-[1000]";
+
+ // Backdrop
+ const backdrop = document.createElement("div");
+ backdrop.className = "bg-black opacity-25 !w-full !h-full z-[1001]";
+ backdrop.onclick = () => closeDialog(dialogId, resolve, !isConfirm);
+
+ // Dialog box
+ const dialogBox = document.createElement("div");
+ dialogBox.className =
+ "absolute z-[1002] top-[50%] left-[50%] translate-y-[-50%] translate-x-[-50%] " +
+ "max-w-[calc(100dvw-30px)] bg-base-100 rounded-box p-6 w-[400px] shadow-xl";
+
+ // Icon
+ const iconPath = isConfirm
+ ? "M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+ : "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z";
+ const iconColor = isConfirm ? "text-warning" : "text-info";
+
+ dialogBox.innerHTML = `
+