diff --git a/internal/handlers/app.go b/internal/handlers/app.go index 1e03be6..c42b8d9 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -903,9 +903,16 @@ func (h *Handlers) addKeyValueToApp( http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) } +// envPairJSON represents a key-value pair in the JSON request body. +type envPairJSON struct { + Key string `json:"key"` + Value string `json:"value"` +} + // HandleEnvVarSave handles bulk saving of all environment variables. -// It deletes all existing env vars for the app and inserts the full -// submitted set (monolithic list approach). +// It reads a JSON array of {key, value} objects from the request body, +// deletes all existing env vars for the app, and inserts the full +// submitted set. func (h *Handlers) HandleEnvVarSave() http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { appID := chi.URLParam(request, "id") @@ -917,14 +924,15 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc { return } - parseErr := request.ParseForm() - if parseErr != nil { + var pairs []envPairJSON + + decodeErr := json.NewDecoder(request.Body).Decode(&pairs) + if decodeErr != nil { http.Error(writer, "Bad Request", http.StatusBadRequest) return } - pairs := parseEnvPairs(request.FormValue("env_vars")) ctx := request.Context() // Delete all existing env vars for this app @@ -944,59 +952,23 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc { for _, p := range pairs { envVar := models.NewEnvVar(h.db) envVar.AppID = application.ID - envVar.Key = p.key - envVar.Value = p.value + envVar.Key = p.Key + envVar.Value = p.Value saveErr := envVar.Save(ctx) if saveErr != nil { h.log.Error( "failed to save env var", - "key", p.key, + "key", p.Key, "error", saveErr, ) } } - http.Redirect( - writer, - request, - "/apps/"+appID+"?success=env-updated", - http.StatusSeeOther, - ) + h.respondJSON(writer, request, map[string]bool{"ok": true}, http.StatusOK) } } -// envPair holds a parsed key-value pair from the env vars textarea. -type envPair struct { - key string - value string -} - -// parseEnvPairs parses KEY=VALUE lines from a textarea string. -// Blank lines and lines starting with # are skipped. -func parseEnvPairs(text string) []envPair { - var pairs []envPair - - for line := range strings.SplitSeq(text, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - key, value, ok := strings.Cut(line, "=") - if !ok || strings.TrimSpace(key) == "" { - continue - } - - pairs = append(pairs, envPair{ - key: strings.TrimSpace(key), - value: value, - }) - } - - return pairs -} - // HandleLabelAdd handles adding a label. func (h *Handlers) HandleLabelAdd() http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 86d1323..54a3631 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -577,9 +577,8 @@ func TestHandleEnvVarSaveBulk(t *testing.T) { require.NoError(t, ev.Save(context.Background())) } - // Submit a new set via textarea - form := url.Values{} - form.Set("env_vars", "NEW_KEY=new_value\nANOTHER=42\n# comment line\n") + // Submit a new set as a JSON array of key/value objects + body := `[{"key":"NEW_KEY","value":"new_value"},{"key":"ANOTHER","value":"42"}]` r := chi.NewRouter() r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) @@ -587,14 +586,14 @@ func TestHandleEnvVarSaveBulk(t *testing.T) { request := httptest.NewRequest( http.MethodPost, "/apps/"+createdApp.ID+"/env", - strings.NewReader(form.Encode()), + strings.NewReader(body), ) - request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() r.ServeHTTP(recorder, request) - assert.Equal(t, http.StatusSeeOther, recorder.Code) + assert.Equal(t, http.StatusOK, recorder.Code) // Verify old env vars are gone and new ones exist envVars, err := models.FindEnvVarsByAppID( @@ -621,8 +620,7 @@ func TestHandleEnvVarSaveAppNotFound(t *testing.T) { testCtx := setupTestHandlers(t) - form := url.Values{} - form.Set("env_vars", "KEY=value\n") + body := `[{"key":"KEY","value":"value"}]` r := chi.NewRouter() r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) @@ -630,9 +628,9 @@ func TestHandleEnvVarSaveAppNotFound(t *testing.T) { request := httptest.NewRequest( http.MethodPost, "/apps/nonexistent-id/env", - strings.NewReader(form.Encode()), + strings.NewReader(body), ) - request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() r.ServeHTTP(recorder, request) @@ -758,8 +756,8 @@ func TestDeletePortOwnershipVerification(t *testing.T) { assert.NotNil(t, found, "port should still exist after IDOR attempt") } -// TestHandleEnvVarSaveEmptyClears verifies that submitting an empty textarea -// deletes all existing env vars for the app. +// TestHandleEnvVarSaveEmptyClears verifies that submitting an empty JSON +// array deletes all existing env vars for the app. func TestHandleEnvVarSaveEmptyClears(t *testing.T) { t.Parallel() @@ -773,24 +771,21 @@ func TestHandleEnvVarSaveEmptyClears(t *testing.T) { ev.Value = "gone" require.NoError(t, ev.Save(context.Background())) - // Submit empty textarea - form := url.Values{} - form.Set("env_vars", "") - + // Submit empty JSON array r := chi.NewRouter() r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) request := httptest.NewRequest( http.MethodPost, "/apps/"+createdApp.ID+"/env", - strings.NewReader(form.Encode()), + strings.NewReader("[]"), ) - request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() r.ServeHTTP(recorder, request) - assert.Equal(t, http.StatusSeeOther, recorder.Code) + assert.Equal(t, http.StatusOK, recorder.Code) // Verify all env vars are gone envVars, err := models.FindEnvVarsByAppID( diff --git a/static/js/app-detail.js b/static/js/app-detail.js index 8636d54..60f6ded 100644 --- a/static/js/app-detail.js +++ b/static/js/app-detail.js @@ -9,11 +9,12 @@ document.addEventListener("alpine:init", () => { // ============================================ // Environment Variable Editor Component // ============================================ - Alpine.data("envVarEditor", () => ({ + Alpine.data("envVarEditor", (appId) => ({ vars: [], editIdx: -1, editKey: "", editVal: "", + appId: appId, init() { this.vars = Array.from(this.$el.querySelectorAll(".env-init")).map( @@ -58,10 +59,25 @@ document.addEventListener("alpine:init", () => { }, submitAll() { - this.$refs.bulkData.value = this.vars - .map((e) => e.key + "=" + e.value) - .join("\n"); - this.$refs.bulkForm.submit(); + const csrfInput = this.$el.querySelector( + 'input[name="gorilla.csrf.Token"]', + ); + const csrfToken = csrfInput ? csrfInput.value : ""; + + fetch("/apps/" + this.appId + "/env", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken, + }, + body: JSON.stringify( + this.vars.map((e) => ({ key: e.key, value: e.value })), + ), + }).then((res) => { + if (res.ok) { + window.location.reload(); + } + }); }, })); diff --git a/templates/app_detail.html b/templates/app_detail.html index 0e10cf9..b80ad87 100644 --- a/templates/app_detail.html +++ b/templates/app_detail.html @@ -101,7 +101,7 @@ -
+

Environment Variables

{{range .EnvVars}}{{end}}