refactor: POST env vars as JSON array instead of KEY=value string
All checks were successful
Check / check (pull_request) Successful in 4s

Replace the string-serialized KEY=value format with a proper JSON array
of {key, value} objects for the env var save endpoint.

Frontend changes:
- envVarEditor.submitAll() now uses fetch() with Content-Type:
  application/json and X-CSRF-Token header instead of form submission
- Sends JSON array: [{"key":"FOO","value":"bar"}, ...]
- Hidden bulk form replaced with hidden div holding CSRF token
- envVarEditor now receives appId parameter for the fetch URL

Backend changes:
- HandleEnvVarSave reads JSON body via json.NewDecoder instead of
  parsing form values with parseEnvPairs
- Returns JSON {"ok": true} instead of HTTP redirect
- Removed parseEnvPairs function and envPair struct entirely
- Added envPairJSON struct with json tags for deserialization

Tests updated to POST JSON arrays instead of form-encoded strings.

Closes #163
This commit is contained in:
clawbot
2026-03-10 11:37:55 -07:00
parent 3f96f4f81b
commit df6aad9b21
4 changed files with 54 additions and 74 deletions

View File

@@ -903,9 +903,16 @@ func (h *Handlers) addKeyValueToApp(
http.Redirect(writer, request, "/apps/"+application.ID, http.StatusSeeOther) 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. // HandleEnvVarSave handles bulk saving of all environment variables.
// It deletes all existing env vars for the app and inserts the full // It reads a JSON array of {key, value} objects from the request body,
// submitted set (monolithic list approach). // deletes all existing env vars for the app, and inserts the full
// submitted set.
func (h *Handlers) HandleEnvVarSave() http.HandlerFunc { func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id") appID := chi.URLParam(request, "id")
@@ -917,14 +924,15 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
return return
} }
parseErr := request.ParseForm() var pairs []envPairJSON
if parseErr != nil {
decodeErr := json.NewDecoder(request.Body).Decode(&pairs)
if decodeErr != nil {
http.Error(writer, "Bad Request", http.StatusBadRequest) http.Error(writer, "Bad Request", http.StatusBadRequest)
return return
} }
pairs := parseEnvPairs(request.FormValue("env_vars"))
ctx := request.Context() ctx := request.Context()
// Delete all existing env vars for this app // Delete all existing env vars for this app
@@ -944,59 +952,23 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
for _, p := range pairs { for _, p := range pairs {
envVar := models.NewEnvVar(h.db) envVar := models.NewEnvVar(h.db)
envVar.AppID = application.ID envVar.AppID = application.ID
envVar.Key = p.key envVar.Key = p.Key
envVar.Value = p.value envVar.Value = p.Value
saveErr := envVar.Save(ctx) saveErr := envVar.Save(ctx)
if saveErr != nil { if saveErr != nil {
h.log.Error( h.log.Error(
"failed to save env var", "failed to save env var",
"key", p.key, "key", p.Key,
"error", saveErr, "error", saveErr,
) )
} }
} }
http.Redirect( h.respondJSON(writer, request, map[string]bool{"ok": true}, http.StatusOK)
writer,
request,
"/apps/"+appID+"?success=env-updated",
http.StatusSeeOther,
)
} }
} }
// 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. // HandleLabelAdd handles adding a label.
func (h *Handlers) HandleLabelAdd() http.HandlerFunc { func (h *Handlers) HandleLabelAdd() http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) { return func(writer http.ResponseWriter, request *http.Request) {

View File

@@ -577,9 +577,8 @@ func TestHandleEnvVarSaveBulk(t *testing.T) {
require.NoError(t, ev.Save(context.Background())) require.NoError(t, ev.Save(context.Background()))
} }
// Submit a new set via textarea // Submit a new set as a JSON array of key/value objects
form := url.Values{} body := `[{"key":"NEW_KEY","value":"new_value"},{"key":"ANOTHER","value":"42"}]`
form.Set("env_vars", "NEW_KEY=new_value\nANOTHER=42\n# comment line\n")
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
@@ -587,14 +586,14 @@ func TestHandleEnvVarSaveBulk(t *testing.T) {
request := httptest.NewRequest( request := httptest.NewRequest(
http.MethodPost, http.MethodPost,
"/apps/"+createdApp.ID+"/env", "/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() recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request) 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 // Verify old env vars are gone and new ones exist
envVars, err := models.FindEnvVarsByAppID( envVars, err := models.FindEnvVarsByAppID(
@@ -621,8 +620,7 @@ func TestHandleEnvVarSaveAppNotFound(t *testing.T) {
testCtx := setupTestHandlers(t) testCtx := setupTestHandlers(t)
form := url.Values{} body := `[{"key":"KEY","value":"value"}]`
form.Set("env_vars", "KEY=value\n")
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
@@ -630,9 +628,9 @@ func TestHandleEnvVarSaveAppNotFound(t *testing.T) {
request := httptest.NewRequest( request := httptest.NewRequest(
http.MethodPost, http.MethodPost,
"/apps/nonexistent-id/env", "/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() recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request) r.ServeHTTP(recorder, request)
@@ -758,8 +756,8 @@ func TestDeletePortOwnershipVerification(t *testing.T) {
assert.NotNil(t, found, "port should still exist after IDOR attempt") assert.NotNil(t, found, "port should still exist after IDOR attempt")
} }
// TestHandleEnvVarSaveEmptyClears verifies that submitting an empty textarea // TestHandleEnvVarSaveEmptyClears verifies that submitting an empty JSON
// deletes all existing env vars for the app. // array deletes all existing env vars for the app.
func TestHandleEnvVarSaveEmptyClears(t *testing.T) { func TestHandleEnvVarSaveEmptyClears(t *testing.T) {
t.Parallel() t.Parallel()
@@ -773,24 +771,21 @@ func TestHandleEnvVarSaveEmptyClears(t *testing.T) {
ev.Value = "gone" ev.Value = "gone"
require.NoError(t, ev.Save(context.Background())) require.NoError(t, ev.Save(context.Background()))
// Submit empty textarea // Submit empty JSON array
form := url.Values{}
form.Set("env_vars", "")
r := chi.NewRouter() r := chi.NewRouter()
r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave())
request := httptest.NewRequest( request := httptest.NewRequest(
http.MethodPost, http.MethodPost,
"/apps/"+createdApp.ID+"/env", "/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() recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request) r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code) assert.Equal(t, http.StatusOK, recorder.Code)
// Verify all env vars are gone // Verify all env vars are gone
envVars, err := models.FindEnvVarsByAppID( envVars, err := models.FindEnvVarsByAppID(

View File

@@ -9,11 +9,12 @@ document.addEventListener("alpine:init", () => {
// ============================================ // ============================================
// Environment Variable Editor Component // Environment Variable Editor Component
// ============================================ // ============================================
Alpine.data("envVarEditor", () => ({ Alpine.data("envVarEditor", (appId) => ({
vars: [], vars: [],
editIdx: -1, editIdx: -1,
editKey: "", editKey: "",
editVal: "", editVal: "",
appId: appId,
init() { init() {
this.vars = Array.from(this.$el.querySelectorAll(".env-init")).map( this.vars = Array.from(this.$el.querySelectorAll(".env-init")).map(
@@ -58,10 +59,25 @@ document.addEventListener("alpine:init", () => {
}, },
submitAll() { submitAll() {
this.$refs.bulkData.value = this.vars const csrfInput = this.$el.querySelector(
.map((e) => e.key + "=" + e.value) 'input[name="gorilla.csrf.Token"]',
.join("\n"); );
this.$refs.bulkForm.submit(); 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();
}
});
}, },
})); }));

View File

@@ -101,7 +101,7 @@
</div> </div>
<!-- Environment Variables --> <!-- Environment Variables -->
<div class="card p-6 mb-6" x-data="envVarEditor()"> <div class="card p-6 mb-6" x-data="envVarEditor('{{.App.ID}}')">
<h2 class="section-title mb-4">Environment Variables</h2> <h2 class="section-title mb-4">Environment Variables</h2>
{{range .EnvVars}}<span class="env-init hidden" data-key="{{.Key}}" data-value="{{.Value}}"></span>{{end}} {{range .EnvVars}}<span class="env-init hidden" data-key="{{.Key}}" data-value="{{.Value}}"></span>{{end}}
<template x-if="vars.length > 0"> <template x-if="vars.length > 0">
@@ -151,10 +151,7 @@
<input x-ref="newVal" type="text" placeholder="value" required class="input flex-1 font-mono text-sm"> <input x-ref="newVal" type="text" placeholder="value" required class="input flex-1 font-mono text-sm">
<button type="submit" class="btn-primary">Add</button> <button type="submit" class="btn-primary">Add</button>
</form> </form>
<form x-ref="bulkForm" method="POST" action="/apps/{{.App.ID}}/env" class="hidden"> <div class="hidden">{{ .CSRFField }}</div>
{{ .CSRFField }}
<textarea x-ref="bulkData" name="env_vars"></textarea>
</form>
</div> </div>
<!-- Labels --> <!-- Labels -->