From e9d284698a9cd91bb15f09ebe364b0e03bb66d2a Mon Sep 17 00:00:00 2001 From: clawbot Date: Mon, 16 Feb 2026 00:26:07 -0800 Subject: [PATCH] feat: edit existing env vars, labels, and volume mounts Add inline edit functionality for environment variables, labels, and volume mounts on the app detail page. Each entity row now has an Edit button that reveals an inline form using Alpine.js. - POST /apps/{id}/env-vars/{varID}/edit - POST /apps/{id}/labels/{labelID}/edit - POST /apps/{id}/volumes/{volumeID}/edit - Path validation for volume host and container paths - Warning banner about container restart after env var changes - Tests for ValidateVolumePath fixes #67 --- internal/handlers/app.go | 203 ++++++++++++++++++++ internal/handlers/volume_validation_test.go | 34 ++++ internal/server/routes.go | 3 + templates/app_detail.html | 129 +++++++++---- 4 files changed, 335 insertions(+), 34 deletions(-) create mode 100644 internal/handlers/volume_validation_test.go diff --git a/internal/handlers/app.go b/internal/handlers/app.go index 492fa9a..5c58da4 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -4,6 +4,8 @@ import ( "context" "database/sql" "encoding/json" + "errors" + "fmt" "net/http" "os" "path/filepath" @@ -1116,6 +1118,207 @@ func (h *Handlers) HandlePortDelete() http.HandlerFunc { } } +// ErrVolumePathEmpty is returned when a volume path is empty. +var ErrVolumePathEmpty = errors.New("path must not be empty") + +// ErrVolumePathNotAbsolute is returned when a volume path is not absolute. +var ErrVolumePathNotAbsolute = errors.New("path must be absolute") + +// ErrVolumePathNotClean is returned when a volume path is not clean. +var ErrVolumePathNotClean = errors.New("path must be clean") + +// ValidateVolumePath checks that a path is absolute and clean. +func ValidateVolumePath(p string) error { + if p == "" { + return ErrVolumePathEmpty + } + + if !filepath.IsAbs(p) { + return ErrVolumePathNotAbsolute + } + + cleaned := filepath.Clean(p) + if cleaned != p { + return fmt.Errorf("%w (expected %q)", ErrVolumePathNotClean, cleaned) + } + + return nil +} + +// HandleEnvVarEdit handles editing an existing environment variable. +func (h *Handlers) HandleEnvVarEdit() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + envVarIDStr := chi.URLParam(request, "varID") + + envVarID, parseErr := strconv.ParseInt(envVarIDStr, 10, 64) + if parseErr != nil { + http.NotFound(writer, request) + + return + } + + envVar, findErr := models.FindEnvVar(request.Context(), h.db, envVarID) + if findErr != nil || envVar == nil || envVar.AppID != appID { + http.NotFound(writer, request) + + return + } + + formErr := request.ParseForm() + if formErr != nil { + http.Error(writer, "Bad Request", http.StatusBadRequest) + + return + } + + key := request.FormValue("key") + value := request.FormValue("value") + + if key == "" || value == "" { + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + + return + } + + envVar.Key = key + envVar.Value = value + + saveErr := envVar.Save(request.Context()) + if saveErr != nil { + h.log.Error("failed to update env var", "error", saveErr) + } + + http.Redirect( + writer, + request, + "/apps/"+appID+"?success=env-updated", + http.StatusSeeOther, + ) + } +} + +// HandleLabelEdit handles editing an existing label. +func (h *Handlers) HandleLabelEdit() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + labelIDStr := chi.URLParam(request, "labelID") + + labelID, parseErr := strconv.ParseInt(labelIDStr, 10, 64) + if parseErr != nil { + http.NotFound(writer, request) + + return + } + + label, findErr := models.FindLabel(request.Context(), h.db, labelID) + if findErr != nil || label == nil || label.AppID != appID { + http.NotFound(writer, request) + + return + } + + formErr := request.ParseForm() + if formErr != nil { + http.Error(writer, "Bad Request", http.StatusBadRequest) + + return + } + + key := request.FormValue("key") + value := request.FormValue("value") + + if key == "" || value == "" { + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + + return + } + + label.Key = key + label.Value = value + + saveErr := label.Save(request.Context()) + if saveErr != nil { + h.log.Error("failed to update label", "error", saveErr) + } + + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + } +} + +// HandleVolumeEdit handles editing an existing volume mount. +func (h *Handlers) HandleVolumeEdit() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + volumeIDStr := chi.URLParam(request, "volumeID") + + volumeID, parseErr := strconv.ParseInt(volumeIDStr, 10, 64) + if parseErr != nil { + http.NotFound(writer, request) + + return + } + + volume, findErr := models.FindVolume(request.Context(), h.db, volumeID) + if findErr != nil || volume == nil || volume.AppID != appID { + http.NotFound(writer, request) + + return + } + + formErr := request.ParseForm() + if formErr != nil { + http.Error(writer, "Bad Request", http.StatusBadRequest) + + return + } + + hostPath := request.FormValue("host_path") + containerPath := request.FormValue("container_path") + readOnly := request.FormValue("readonly") == "1" + + if hostPath == "" || containerPath == "" { + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + + return + } + + pathErr := validateVolumePaths(hostPath, containerPath) + if pathErr != nil { + h.log.Error("invalid volume path", "error", pathErr) + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + + return + } + + volume.HostPath = hostPath + volume.ContainerPath = containerPath + volume.ReadOnly = readOnly + + saveErr := volume.Save(request.Context()) + if saveErr != nil { + h.log.Error("failed to update volume", "error", saveErr) + } + + http.Redirect(writer, request, "/apps/"+appID, http.StatusSeeOther) + } +} + +// validateVolumePaths validates both host and container paths for a volume. +func validateVolumePaths(hostPath, containerPath string) error { + hostErr := ValidateVolumePath(hostPath) + if hostErr != nil { + return fmt.Errorf("host path: %w", hostErr) + } + + containerErr := ValidateVolumePath(containerPath) + if containerErr != nil { + return fmt.Errorf("container path: %w", containerErr) + } + + return nil +} + // formatDeployKey formats an SSH public key with a descriptive comment. // Format: ssh-ed25519 AAAA... upaas_2025-01-15_myapp func formatDeployKey(pubKey string, createdAt time.Time, appName string) string { diff --git a/internal/handlers/volume_validation_test.go b/internal/handlers/volume_validation_test.go new file mode 100644 index 0000000..9389fe3 --- /dev/null +++ b/internal/handlers/volume_validation_test.go @@ -0,0 +1,34 @@ +package handlers //nolint:testpackage // tests exported ValidateVolumePath function + +import "testing" + +func TestValidateVolumePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantErr bool + }{ + {"valid absolute path", "/data/myapp", false}, + {"root path", "/", false}, + {"empty path", "", true}, + {"relative path", "data/myapp", true}, + {"path with dotdot", "/data/../etc", true}, + {"path with trailing slash", "/data/", true}, + {"path with double slash", "/data//myapp", true}, + {"single dot path", ".", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateVolumePath(tt.path) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateVolumePath(%q) error = %v, wantErr %v", + tt.path, err, tt.wantErr) + } + }) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 3acadea..fc4cc7f 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -82,14 +82,17 @@ func (s *Server) SetupRoutes() { // Environment variables r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd()) + r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit()) r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete()) // Labels r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd()) + r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit()) r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete()) // Volumes r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd()) + r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit()) r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete()) // Ports diff --git a/templates/app_detail.html b/templates/app_detail.html index 14fb91e..ce2b812 100644 --- a/templates/app_detail.html +++ b/templates/app_detail.html @@ -106,15 +106,34 @@ {{range .EnvVars}} - - {{.Key}} - {{.Value}} - -
- {{ .CSRFField }} - -
- + + + + + {{end}} @@ -151,15 +170,33 @@ {{range .Labels}} - - {{.Key}} - {{.Value}} - -
- {{ .CSRFField }} - -
- + + + + + {{end}} @@ -189,22 +226,46 @@ {{range .Volumes}} - - {{.HostPath}} - {{.ContainerPath}} - - {{if .ReadOnly}} - Read-only - {{else}} - Read-write - {{end}} - - -
- {{ .CSRFField }} - -
- + + + + + + {{end}}