Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ services:
environment:
PBW_ENCRYPTION_KEY: "my_secret_key" # Change this to a strong key
PBW_POSTGRES_CONN_STRING: "postgresql://postgres:password@postgres:5432/pgbackweb?sslmode=disable"
# PBW_PATH_PREFIX: "/pgbackweb" # Optional: Use this if serving under a subpath
TZ: "America/Guatemala" # Set your timezone, optional
depends_on:
postgres:
Expand Down Expand Up @@ -119,6 +120,10 @@ You only need to configure the following environment variables:

- `PBW_LISTEN_PORT`: Port for the server to listen on, default 8085 (optional)

- `PBW_PATH_PREFIX`: Path prefix for the application URL. Use this when serving
the application under a subpath (e.g., `/pgbackweb`). Must start with `/` and
not end with `/`. Default is empty (optional)

- `TZ`: Your
[timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List)
(optional). Default is `UTC`. This impacts logging, backup filenames and
Expand Down
6 changes: 5 additions & 1 deletion cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/eduardolat/pgbackweb/internal/integration"
"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"
"github.com/labstack/echo/v4"
)
Expand All @@ -18,6 +19,9 @@ func main() {
logger.FatalError("error getting environment variables", logger.KV{"error": err})
}

// Initialize the path prefix utility
pathutil.SetPathPrefix(env.PBW_PATH_PREFIX)

cr, err := cron.New()
if err != nil {
logger.FatalError("error initializing cron scheduler", logger.KV{"error": err})
Expand All @@ -44,7 +48,7 @@ func main() {
app := echo.New()
app.HideBanner = true
app.HidePort = true
view.MountRouter(app, servs)
view.MountRouter(app, servs, env)

address := env.PBW_LISTEN_HOST + ":" + env.PBW_LISTEN_PORT
logger.Info("server started at http://localhost:"+env.PBW_LISTEN_PORT, logger.KV{
Expand Down
1 change: 1 addition & 0 deletions internal/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
4 changes: 4 additions & 0 deletions internal/config/env_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
35 changes: 35 additions & 0 deletions internal/util/pathutil/pathutil.go
Original file line number Diff line number Diff line change
@@ -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
}
87 changes: 87 additions & 0 deletions internal/util/pathutil/pathutil_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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")
}
}
43 changes: 43 additions & 0 deletions internal/validate/path_prefix.go
Original file line number Diff line number Diff line change
@@ -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
}
78 changes: 78 additions & 0 deletions internal/validate/path_prefix_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
11 changes: 7 additions & 4 deletions internal/view/middleware/require_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
}
}
6 changes: 4 additions & 2 deletions internal/view/middleware/require_no_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down
Loading