refactor: POST env vars as JSON array instead of KEY=value string
All checks were successful
Check / check (pull_request) Successful in 4s
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:
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
{{range .EnvVars}}<span class="env-init hidden" data-key="{{.Key}}" data-value="{{.Value}}"></span>{{end}}
|
||||
<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">
|
||||
<button type="submit" class="btn-primary">Add</button>
|
||||
</form>
|
||||
<form x-ref="bulkForm" method="POST" action="/apps/{{.App.ID}}/env" class="hidden">
|
||||
{{ .CSRFField }}
|
||||
<textarea x-ref="bulkData" name="env_vars"></textarea>
|
||||
</form>
|
||||
<div class="hidden">{{ .CSRFField }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Labels -->
|
||||
|
||||
Reference in New Issue
Block a user