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}}