From 911c831aadcd8b5d5c3fbe55515ccb0b2a095a60 Mon Sep 17 00:00:00 2001 From: Indra Gunawan <1284811+coderbuzz@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:32:22 +0700 Subject: [PATCH 01/10] fix: keep the base path in redirects and template links (B8) Move SetBasePath/GetBasePath into internal/web with a Redirect helper that prepends the base path, use it for every redirect in the app/server handlers and auth, and make the remaining absolute template links relative to . Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- SPEC.md | 2 +- internal/app/handler.go | 25 ++++++++-------- internal/http/auth.go | 8 ++++-- internal/http/redirect_test.go | 32 +++++++++++++++++++++ internal/http/router.go | 5 ++-- internal/http/templates.go | 17 ++--------- internal/http/templates/apps_add.html | 2 +- internal/http/templates/export.html | 2 +- internal/http/templates/import.html | 2 +- internal/http/templates/servers_detail.html | 4 +-- internal/http/templates/settings.html | 4 +-- internal/server/handler.go | 13 +++++---- internal/web/web.go | 31 ++++++++++++++++++++ 14 files changed, 102 insertions(+), 47 deletions(-) create mode 100644 internal/http/redirect_test.go create mode 100644 internal/web/web.go diff --git a/README.md b/README.md index 6971d15..f115119 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Create a `.env` file in the project root or set these environment variables: | `DOCKIFY_DEV_MOCK` | `false` | No | Enable mock SSH for local development (no real VMs needed) | | `CLOUDFLARE_API_TOKEN` | — | No | Cloudflare API token for automatic DNS A records | | `CLOUDFLARE_ZONE_ID` | — | No | Cloudflare zone ID | -| `DOCKIFY_BASE_PATH` | — | No | URL prefix when behind a reverse proxy (e.g. `/proxy/9898`) | +| `DOCKIFY_BASE_PATH` | — | No | URL prefix when behind a reverse proxy (e.g. `/proxy/9898`); links and redirects stay under it. A proxy can send `X-Forwarded-Prefix` per request instead | ### Authentication diff --git a/SPEC.md b/SPEC.md index f96cbed..2771b52 100644 --- a/SPEC.md +++ b/SPEC.md @@ -106,7 +106,7 @@ docker-compose.yml # Dockify + Caddy reverse proxy (mode 1) | `DOCKIFY_ALLOW_NO_AUTH` | `false` | No | Allow a passwordless UI on a non-loopback bind | | `DOCKIFY_API_TOKEN` | — | No | `Authorization: Bearer` token accepted on all protected routes (scripts/agents) | | `DOCKIFY_DEV_MOCK` | `false` | No | Enable mock SSH client for local development | -| `DOCKIFY_BASE_PATH` | — | No | URL prefix when behind reverse proxy (e.g. `/proxy/9898`) | +| `DOCKIFY_BASE_PATH` | — | No | URL prefix when behind reverse proxy (e.g. `/proxy/9898`); `X-Forwarded-Prefix` overrides it per request. Templates use links relative to ``, redirects go through `web.Redirect`, which prepends the prefix | | `CLOUDFLARE_API_TOKEN` | — | No | Cloudflare API token for DNS automation (Zone:DNS:Edit scope) | | `CLOUDFLARE_ZONE_ID` | — | No | Cloudflare zone ID for the domain | diff --git a/internal/app/handler.go b/internal/app/handler.go index fcf0d77..72489d4 100644 --- a/internal/app/handler.go +++ b/internal/app/handler.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "github.com/coderbuzz/dockify/internal/web" "github.com/go-chi/chi/v5" ) @@ -822,7 +823,7 @@ func (h *WebHandler) AppAddForm(w http.ResponseWriter, r *http.Request, render R go h.service.Deploy(app.ID) } - http.Redirect(w, r, "/apps/"+strconv.FormatInt(app.ID, 10), http.StatusSeeOther) + web.Redirect(w, r, "/apps/"+strconv.FormatInt(app.ID, 10), http.StatusSeeOther) } func (h *WebHandler) AppEditPage(w http.ResponseWriter, r *http.Request, render RenderFunc) { @@ -1100,7 +1101,7 @@ func (h *WebHandler) AppEditForm(w http.ResponseWriter, r *http.Request, render if flashMsg != "" { redirectURL += "?flash=" + flashMsg } - http.Redirect(w, r, redirectURL, http.StatusSeeOther) + web.Redirect(w, r, redirectURL, http.StatusSeeOther) } func (h *WebHandler) AppDetailPage(w http.ResponseWriter, r *http.Request, render RenderFunc) { @@ -1173,7 +1174,7 @@ func (h *WebHandler) refuseBusy(w http.ResponseWriter, r *http.Request, id int64 if !h.service.Busy(id) { return false } - http.Redirect(w, r, fmt.Sprintf("/apps/%d?flash=%s", id, url.QueryEscape(busyMsg)), http.StatusSeeOther) + web.Redirect(w, r, fmt.Sprintf("/apps/%d?flash=%s", id, url.QueryEscape(busyMsg)), http.StatusSeeOther) return true } @@ -1185,7 +1186,7 @@ func (h *WebHandler) AppDeleteWeb(w http.ResponseWriter, r *http.Request, render go h.service.Undeploy(id) - http.Redirect(w, r, "/apps", http.StatusSeeOther) + web.Redirect(w, r, "/apps", http.StatusSeeOther) } func (h *WebHandler) AppClearDeploymentsWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) { @@ -1193,7 +1194,7 @@ func (h *WebHandler) AppClearDeploymentsWeb(w http.ResponseWriter, r *http.Reque _ = h.service.ClearDeployments(id) - http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther) + web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther) } func (h *WebHandler) AppRedeployWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) { @@ -1204,7 +1205,7 @@ func (h *WebHandler) AppRedeployWeb(w http.ResponseWriter, r *http.Request, rend go h.service.Redeploy(id) - http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther) + web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther) } func (h *WebHandler) AppDeployWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) { @@ -1217,7 +1218,7 @@ func (h *WebHandler) AppDeployWeb(w http.ResponseWriter, r *http.Request, render } if app.ServerID == 0 { - http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther) + web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10)+"/edit", http.StatusSeeOther) return } if h.refuseBusy(w, r, id) { @@ -1227,7 +1228,7 @@ func (h *WebHandler) AppDeployWeb(w http.ResponseWriter, r *http.Request, render h.service.UpdateStatus(id, StatusDeploying) go h.service.Redeploy(id) - http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther) + web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther) } func (h *WebHandler) AppRollbackWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) { @@ -1241,7 +1242,7 @@ func (h *WebHandler) AppRollbackWeb(w http.ResponseWriter, r *http.Request, rend return } - http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther) + web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther) } func (h *WebHandler) AppStopWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) { @@ -1255,7 +1256,7 @@ func (h *WebHandler) AppStopWeb(w http.ResponseWriter, r *http.Request, render R return } - http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther) + web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther) } func (h *WebHandler) AppStartWeb(w http.ResponseWriter, r *http.Request, render RenderFunc) { @@ -1269,7 +1270,7 @@ func (h *WebHandler) AppStartWeb(w http.ResponseWriter, r *http.Request, render return } - http.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther) + web.Redirect(w, r, "/apps/"+strconv.FormatInt(id, 10), http.StatusSeeOther) } func (h *WebHandler) AppRedeployAll(w http.ResponseWriter, r *http.Request, render RenderFunc) { @@ -1287,7 +1288,7 @@ func (h *WebHandler) AppRedeployAll(w http.ResponseWriter, r *http.Request, rend } } - http.Redirect(w, r, "/apps?flash="+url.QueryEscape(fmt.Sprintf("Redeploying %d running apps...", count)), http.StatusSeeOther) + web.Redirect(w, r, "/apps?flash="+url.QueryEscape(fmt.Sprintf("Redeploying %d running apps...", count)), http.StatusSeeOther) } func saveFormEnvVars(r *http.Request, svc *Service, appID int64) { diff --git a/internal/http/auth.go b/internal/http/auth.go index b3d9c29..bbfd09a 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -9,6 +9,8 @@ import ( "strings" "sync" "time" + + "github.com/coderbuzz/dockify/internal/web" ) var ( @@ -99,7 +101,7 @@ func AuthMiddleware(enabled bool, apiToken string, next http.Handler) http.Handl return } - http.Redirect(w, r, "/login", http.StatusFound) + web.Redirect(w, r, "/login", http.StatusFound) }) } @@ -119,7 +121,7 @@ func HandleLogin(w http.ResponseWriter, r *http.Request, cfgUser, cfgPass string if user == cfgUser && pass == cfgPass { setSession(w, r) log.Printf("Login: %s", user) - http.Redirect(w, r, "/", http.StatusFound) + web.Redirect(w, r, "/", http.StatusFound) return } @@ -137,5 +139,5 @@ func HandleLogin(w http.ResponseWriter, r *http.Request, cfgUser, cfgPass string func HandleLogout(w http.ResponseWriter, r *http.Request) { clearSession(w, r) - http.Redirect(w, r, "/login", http.StatusFound) + web.Redirect(w, r, "/login", http.StatusFound) } diff --git a/internal/http/redirect_test.go b/internal/http/redirect_test.go new file mode 100644 index 0000000..07d5ef5 --- /dev/null +++ b/internal/http/redirect_test.go @@ -0,0 +1,32 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/go-chi/chi/v5" +) + +// A form POST that redirects keeps the X-Forwarded-Prefix in its Location (B8). +func TestRedirectKeepsBasePath(t *testing.T) { + r := chi.NewRouter() + r.Use(PrefixMiddleware) + r.Post("/login", func(w http.ResponseWriter, r *http.Request) { + HandleLogin(w, r, "admin", "pw", Render) + }) + for prefix, want := range map[string]string{"": "/", "/proxy/9898": "/proxy/9898/"} { + req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(url.Values{"user": {"admin"}, "pass": {"pw"}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if prefix != "" { + req.Header.Set("X-Forwarded-Prefix", prefix) + } + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if got := rec.Header().Get("Location"); rec.Code != http.StatusFound || got != want { + t.Errorf("prefix %q: %d Location %q, want %q", prefix, rec.Code, got, want) + } + } +} diff --git a/internal/http/router.go b/internal/http/router.go index f2da42f..4065bdd 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -9,6 +9,7 @@ import ( "github.com/coderbuzz/dockify/internal/backup" "github.com/coderbuzz/dockify/internal/server" "github.com/coderbuzz/dockify/internal/settings" + "github.com/coderbuzz/dockify/internal/web" "github.com/coderbuzz/dockify/internal/webhook" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" @@ -42,7 +43,7 @@ func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serv r.Get("/login", func(w http.ResponseWriter, r *http.Request) { if !authEnabled || getSessionUser(r) != "" { - http.Redirect(w, r, "/", http.StatusFound) + web.Redirect(w, r, "/", http.StatusFound) return } HandleLogin(w, r, cfgUser, cfgPass, render) @@ -262,7 +263,7 @@ func PrefixMiddleware(next http.Handler) http.Handler { } if prefix != "" { prefix = strings.TrimSuffix(prefix, "/") + "/" - r = SetBasePath(r, prefix) + r = web.SetBasePath(r, prefix) } next.ServeHTTP(w, r) }) diff --git a/internal/http/templates.go b/internal/http/templates.go index 061e5d3..3fb5fb1 100644 --- a/internal/http/templates.go +++ b/internal/http/templates.go @@ -12,6 +12,7 @@ import ( "time" "github.com/coderbuzz/dockify/internal/model" + "github.com/coderbuzz/dockify/internal/web" ) //go:embed templates/*.html @@ -220,21 +221,8 @@ var tmpl = template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "te type contextKey string -const basePathKey contextKey = "basePath" const devMockKey contextKey = "devMock" -func SetBasePath(r *http.Request, path string) *http.Request { - ctx := context.WithValue(r.Context(), basePathKey, path) - return r.WithContext(ctx) -} - -func GetBasePath(r *http.Request) string { - if v, ok := r.Context().Value(basePathKey).(string); ok && v != "" { - return v - } - return "/" -} - func SetDevMock(r *http.Request, val bool) *http.Request { ctx := context.WithValue(r.Context(), devMockKey, val) return r.WithContext(ctx) @@ -254,10 +242,9 @@ func Render(w http.ResponseWriter, r *http.Request, status int, name string, dat return } - basePath := GetBasePath(r) m, ok := data.(map[string]interface{}) if ok { - m["BasePath"] = basePath + m["BasePath"] = web.GetBasePath(r) m["DevMock"] = GetDevMock(r) } diff --git a/internal/http/templates/apps_add.html b/internal/http/templates/apps_add.html index 1209271..10ba1fc 100644 --- a/internal/http/templates/apps_add.html +++ b/internal/http/templates/apps_add.html @@ -182,7 +182,7 @@

Basic Auth Save & Redeploy {{end}} - Cancel + Cancel {{else}} diff --git a/internal/http/templates/export.html b/internal/http/templates/export.html index 350e655..5d8d9e9 100644 --- a/internal/http/templates/export.html +++ b/internal/http/templates/export.html @@ -12,7 +12,7 @@

Export Configuration

-
+

Download Backup

diff --git a/internal/http/templates/import.html b/internal/http/templates/import.html index 298987d..64de146 100644 --- a/internal/http/templates/import.html +++ b/internal/http/templates/import.html @@ -19,7 +19,7 @@

{{.Message}}
{{end}} - +

Upload Export File

@@ -53,7 +53,7 @@

Import

Import servers and apps from a previously exported YAML file. Supports merge and replace modes.

- Go to Import Page + Go to Import Page
- - - + + + +