Skip to content

Commit 1374c4e

Browse files
authored
Merge pull request #139 from PatrikScully/feature/path-prefix
Adds support for path prefix configuration
2 parents 173697b + f39598d commit 1374c4e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+473
-92
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ services:
7979
environment:
8080
PBW_ENCRYPTION_KEY: "my_secret_key" # Change this to a strong key
8181
PBW_POSTGRES_CONN_STRING: "postgresql://postgres:password@postgres:5432/pgbackweb?sslmode=disable"
82+
# PBW_PATH_PREFIX: "/pgbackweb" # Optional: Use this if serving under a subpath
8283
TZ: "America/Guatemala" # Set your timezone, optional
8384
depends_on:
8485
postgres:
@@ -119,6 +120,10 @@ You only need to configure the following environment variables:
119120

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

123+
- `PBW_PATH_PREFIX`: Path prefix for the application URL. Use this when serving
124+
the application under a subpath (e.g., `/pgbackweb`). Must start with `/` and
125+
not end with `/`. Default is empty (optional)
126+
122127
- `TZ`: Your
123128
[timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List)
124129
(optional). Default is `UTC`. This impacts logging, backup filenames and

cmd/app/main.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/eduardolat/pgbackweb/internal/integration"
99
"github.com/eduardolat/pgbackweb/internal/logger"
1010
"github.com/eduardolat/pgbackweb/internal/service"
11+
"github.com/eduardolat/pgbackweb/internal/util/pathutil"
1112
"github.com/eduardolat/pgbackweb/internal/view"
1213
"github.com/labstack/echo/v4"
1314
)
@@ -18,6 +19,9 @@ func main() {
1819
logger.FatalError("error getting environment variables", logger.KV{"error": err})
1920
}
2021

22+
// Initialize the path prefix utility
23+
pathutil.SetPathPrefix(env.PBW_PATH_PREFIX)
24+
2125
cr, err := cron.New()
2226
if err != nil {
2327
logger.FatalError("error initializing cron scheduler", logger.KV{"error": err})
@@ -44,7 +48,7 @@ func main() {
4448
app := echo.New()
4549
app.HideBanner = true
4650
app.HidePort = true
47-
view.MountRouter(app, servs)
51+
view.MountRouter(app, servs, env)
4852

4953
address := env.PBW_LISTEN_HOST + ":" + env.PBW_LISTEN_PORT
5054
logger.Info("server started at http://localhost:"+env.PBW_LISTEN_PORT, logger.KV{

internal/config/env.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type Env struct {
1212
PBW_POSTGRES_CONN_STRING string `env:"PBW_POSTGRES_CONN_STRING,required"`
1313
PBW_LISTEN_HOST string `env:"PBW_LISTEN_HOST" envDefault:"0.0.0.0"`
1414
PBW_LISTEN_PORT string `env:"PBW_LISTEN_PORT" envDefault:"8085"`
15+
PBW_PATH_PREFIX string `env:"PBW_PATH_PREFIX" envDefault:""`
1516
}
1617

1718
var (

internal/config/env_validate.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,9 @@ func validateEnv(env Env) error {
1616
return fmt.Errorf("invalid listen port %s, valid values are 1-65535", env.PBW_LISTEN_PORT)
1717
}
1818

19+
if !validate.PathPrefix(env.PBW_PATH_PREFIX) {
20+
return fmt.Errorf("invalid path prefix %s, must start with / and not end with / (or be empty)", env.PBW_PATH_PREFIX)
21+
}
22+
1923
return nil
2024
}

internal/util/pathutil/pathutil.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package pathutil
2+
3+
import "sync"
4+
5+
var (
6+
pathPrefix string
7+
pathPrefixOnce sync.Once
8+
)
9+
10+
// SetPathPrefix sets the path prefix once. This should be called during
11+
// application initialization with the value from the environment config.
12+
func SetPathPrefix(prefix string) {
13+
pathPrefixOnce.Do(func() {
14+
pathPrefix = prefix
15+
})
16+
}
17+
18+
// GetPathPrefix returns the configured path prefix.
19+
func GetPathPrefix() string {
20+
return pathPrefix
21+
}
22+
23+
// BuildPath constructs a full path by prepending the configured path prefix
24+
// to the given path. If no prefix is configured, returns the path as-is.
25+
//
26+
// Examples:
27+
// - BuildPath("/dashboard") with prefix "/pgbackweb" -> "/pgbackweb/dashboard"
28+
// - BuildPath("/dashboard") with no prefix -> "/dashboard"
29+
// - BuildPath("") with prefix "/pgbackweb" -> "/pgbackweb"
30+
func BuildPath(path string) string {
31+
if pathPrefix == "" {
32+
return path
33+
}
34+
return pathPrefix + path
35+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package pathutil
2+
3+
import (
4+
"sync"
5+
"testing"
6+
)
7+
8+
func TestBuildPath(t *testing.T) {
9+
t.Helper()
10+
11+
tests := []struct {
12+
name string
13+
prefix string
14+
path string
15+
expected string
16+
}{
17+
{
18+
name: "no prefix configured",
19+
prefix: "",
20+
path: "/dashboard",
21+
expected: "/dashboard",
22+
},
23+
{
24+
name: "with prefix - dashboard",
25+
prefix: "/pgbackweb",
26+
path: "/dashboard",
27+
expected: "/pgbackweb/dashboard",
28+
},
29+
{
30+
name: "with prefix - api",
31+
prefix: "/pgbackweb",
32+
path: "/api/v1/health",
33+
expected: "/pgbackweb/api/v1/health",
34+
},
35+
{
36+
name: "with prefix - root",
37+
prefix: "/pgbackweb",
38+
path: "",
39+
expected: "/pgbackweb",
40+
},
41+
{
42+
name: "with prefix - auth",
43+
prefix: "/pgbackweb",
44+
path: "/auth/login",
45+
expected: "/pgbackweb/auth/login",
46+
},
47+
{
48+
name: "empty prefix and empty path",
49+
prefix: "",
50+
path: "",
51+
expected: "",
52+
},
53+
}
54+
55+
for _, tt := range tests {
56+
t.Run(tt.name, func(t *testing.T) {
57+
originalPrefix := pathPrefix
58+
pathPrefixOnce = sync.Once{}
59+
SetPathPrefix(tt.prefix)
60+
defer func() {
61+
pathPrefixOnce = sync.Once{}
62+
pathPrefix = originalPrefix
63+
}()
64+
65+
result := BuildPath(tt.path)
66+
if result != tt.expected {
67+
t.Errorf("BuildPath(%q) with prefix %q = %q, want %q", tt.path, tt.prefix, result, tt.expected)
68+
}
69+
})
70+
}
71+
}
72+
73+
func TestGetPathPrefix(t *testing.T) {
74+
t.Helper()
75+
originalPrefix := pathPrefix
76+
pathPrefixOnce = sync.Once{}
77+
SetPathPrefix("/test-prefix")
78+
defer func() {
79+
pathPrefixOnce = sync.Once{}
80+
pathPrefix = originalPrefix
81+
}()
82+
83+
result := GetPathPrefix()
84+
if result != "/test-prefix" {
85+
t.Errorf("GetPathPrefix() = %q, want %q", result, "/test-prefix")
86+
}
87+
}

internal/validate/path_prefix.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package validate
2+
3+
import "strings"
4+
5+
// PathPrefix validates that a path prefix is correctly formatted.
6+
//
7+
// Valid path prefixes:
8+
// - Empty string (no prefix)
9+
// - Must start with /
10+
// - Must NOT end with /
11+
// - No whitespace allowed
12+
//
13+
// Examples:
14+
// - "" -> true (no prefix)
15+
// - "/api" -> true
16+
// - "/pgbackweb" -> true
17+
// - "/app/v1" -> true
18+
// - "api" -> false (doesn't start with /)
19+
// - "/api/" -> false (ends with /)
20+
// - "/ api" -> false (contains whitespace)
21+
func PathPrefix(pathPrefix string) bool {
22+
// Empty string is valid (no prefix)
23+
if pathPrefix == "" {
24+
return true
25+
}
26+
27+
// Must start with /
28+
if !strings.HasPrefix(pathPrefix, "/") {
29+
return false
30+
}
31+
32+
// Must NOT end with /
33+
if strings.HasSuffix(pathPrefix, "/") {
34+
return false
35+
}
36+
37+
// No whitespace allowed
38+
if strings.ContainsAny(pathPrefix, " \t\n\r") {
39+
return false
40+
}
41+
42+
return true
43+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package validate
2+
3+
import "testing"
4+
5+
func TestPathPrefix(t *testing.T) {
6+
t.Helper()
7+
8+
tests := []struct {
9+
name string
10+
input string
11+
expected bool
12+
}{
13+
{
14+
name: "empty string is valid",
15+
input: "",
16+
expected: true,
17+
},
18+
{
19+
name: "valid simple path",
20+
input: "/api",
21+
expected: true,
22+
},
23+
{
24+
name: "valid complex path",
25+
input: "/pgbackweb",
26+
expected: true,
27+
},
28+
{
29+
name: "valid nested path",
30+
input: "/app/v1",
31+
expected: true,
32+
},
33+
{
34+
name: "valid deep nested path",
35+
input: "/api/app/v1",
36+
expected: true,
37+
},
38+
{
39+
name: "invalid - doesn't start with slash",
40+
input: "api",
41+
expected: false,
42+
},
43+
{
44+
name: "invalid - ends with slash",
45+
input: "/api/",
46+
expected: false,
47+
},
48+
{
49+
name: "invalid - only slash",
50+
input: "/",
51+
expected: false,
52+
},
53+
{
54+
name: "invalid - contains space",
55+
input: "/api path",
56+
expected: false,
57+
},
58+
{
59+
name: "invalid - contains tab",
60+
input: "/api\tpath",
61+
expected: false,
62+
},
63+
{
64+
name: "invalid - contains newline",
65+
input: "/api\npath",
66+
expected: false,
67+
},
68+
}
69+
70+
for _, tt := range tests {
71+
t.Run(tt.name, func(t *testing.T) {
72+
result := PathPrefix(tt.input)
73+
if result != tt.expected {
74+
t.Errorf("PathPrefix(%q) = %v, want %v", tt.input, result, tt.expected)
75+
}
76+
})
77+
}
78+
}

internal/view/middleware/require_auth.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"net/http"
55

66
"github.com/eduardolat/pgbackweb/internal/logger"
7+
"github.com/eduardolat/pgbackweb/internal/util/pathutil"
78
"github.com/eduardolat/pgbackweb/internal/view/reqctx"
89
"github.com/labstack/echo/v4"
910
htmx "github.com/nodxdev/nodxgo-htmx"
@@ -29,11 +30,13 @@ func (m *Middleware) RequireAuth(next echo.HandlerFunc) echo.HandlerFunc {
2930
}
3031

3132
if usersQty == 0 {
32-
htmx.ServerSetRedirect(c.Response().Header(), "/auth/create-first-user")
33-
return c.Redirect(http.StatusFound, "/auth/create-first-user")
33+
redirectPath := pathutil.BuildPath("/auth/create-first-user")
34+
htmx.ServerSetRedirect(c.Response().Header(), redirectPath)
35+
return c.Redirect(http.StatusFound, redirectPath)
3436
}
3537

36-
htmx.ServerSetRedirect(c.Response().Header(), "/auth/login")
37-
return c.Redirect(http.StatusFound, "/auth/login")
38+
redirectPath := pathutil.BuildPath("/auth/login")
39+
htmx.ServerSetRedirect(c.Response().Header(), redirectPath)
40+
return c.Redirect(http.StatusFound, redirectPath)
3841
}
3942
}

internal/view/middleware/require_no_auth.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package middleware
33
import (
44
"net/http"
55

6+
"github.com/eduardolat/pgbackweb/internal/util/pathutil"
67
"github.com/eduardolat/pgbackweb/internal/view/reqctx"
78
"github.com/labstack/echo/v4"
89
htmx "github.com/nodxdev/nodxgo-htmx"
@@ -13,8 +14,9 @@ func (m *Middleware) RequireNoAuth(next echo.HandlerFunc) echo.HandlerFunc {
1314
reqCtx := reqctx.GetCtx(c)
1415

1516
if reqCtx.IsAuthed {
16-
htmx.ServerSetRedirect(c.Response().Header(), "/dashboard")
17-
return c.Redirect(http.StatusFound, "/dashboard")
17+
redirectPath := pathutil.BuildPath("/dashboard")
18+
htmx.ServerSetRedirect(c.Response().Header(), redirectPath)
19+
return c.Redirect(http.StatusFound, redirectPath)
1820
}
1921

2022
return next(c)

0 commit comments

Comments
 (0)