Skip to content
Merged
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 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)
}
})
}
}

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