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)
|
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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user