fix: transactional env var save, empty key validation, frontend error handling
All checks were successful
Check / check (pull_request) Successful in 4s

- Wrap DELETE + INSERTs in a database transaction via new
  ReplaceEnvVarsByAppID() to prevent silent data loss on partial
  insert failure. Rollback on any error; return 500 instead of 200.
- Add server-side validation rejecting entries with empty keys
  (returns 400 with error message).
- Add frontend error handling for non-2xx responses with user-visible
  alert messages.
- Remove stale //nolint:dupl directives (files no longer duplicate).
This commit is contained in:
clawbot
2026-03-10 12:25:35 -07:00
parent df6aad9b21
commit 5a986aa8fd
4 changed files with 96 additions and 33 deletions

View File

@@ -912,7 +912,7 @@ type envPairJSON struct {
// HandleEnvVarSave handles bulk saving of all environment variables. // HandleEnvVarSave handles bulk saving of all environment variables.
// It reads a JSON array of {key, value} objects from the request body, // 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 // deletes all existing env vars for the app, and inserts the full
// submitted set. // submitted set atomically within a database transaction.
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")
@@ -933,36 +933,36 @@ func (h *Handlers) HandleEnvVarSave() http.HandlerFunc {
return return
} }
ctx := request.Context() // Validate: reject entries with empty keys
var modelPairs []models.EnvVarPair
// Delete all existing env vars for this app for _, p := range pairs {
deleteErr := models.DeleteEnvVarsByAppID(ctx, h.db, application.ID) trimmedKey := strings.TrimSpace(p.Key)
if deleteErr != nil { if trimmedKey == "" {
h.log.Error("failed to delete env vars", "error", deleteErr) h.respondJSON(writer, request, map[string]string{
http.Error( "error": "empty environment variable key is not allowed",
writer, }, http.StatusBadRequest)
"Internal Server Error",
http.StatusInternalServerError,
)
return return
}
modelPairs = append(modelPairs, models.EnvVarPair{
Key: trimmedKey,
Value: p.Value,
})
} }
// Insert the full new set // Atomically replace all env vars in a transaction
for _, p := range pairs { ctx := request.Context()
envVar := models.NewEnvVar(h.db)
envVar.AppID = application.ID
envVar.Key = p.Key
envVar.Value = p.Value
saveErr := envVar.Save(ctx) replaceErr := models.ReplaceEnvVarsByAppID(ctx, h.db, application.ID, modelPairs)
if saveErr != nil { if replaceErr != nil {
h.log.Error( h.log.Error("failed to replace env vars", "error", replaceErr)
"failed to save env var", h.respondJSON(writer, request, map[string]string{
"key", p.Key, "error": "failed to save environment variables",
"error", saveErr, }, http.StatusInternalServerError)
)
} return
} }
h.respondJSON(writer, request, map[string]bool{"ok": true}, http.StatusOK) h.respondJSON(writer, request, map[string]bool{"ok": true}, http.StatusOK)

View File

@@ -1,4 +1,3 @@
//nolint:dupl // Active Record pattern - similar structure to label.go is intentional
package models package models
import ( import (
@@ -139,3 +138,49 @@ func DeleteEnvVarsByAppID(
return err return err
} }
// EnvVarPair is a key-value pair for bulk env var operations.
type EnvVarPair struct {
Key string
Value string
}
// ReplaceEnvVarsByAppID atomically replaces all env vars for an app
// within a single database transaction. It deletes all existing env
// vars and inserts the provided pairs. If any operation fails, the
// entire transaction is rolled back.
func ReplaceEnvVarsByAppID(
ctx context.Context,
db *database.Database,
appID string,
pairs []EnvVarPair,
) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("beginning transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
_, err = tx.ExecContext(ctx, "DELETE FROM app_env_vars WHERE app_id = ?", appID)
if err != nil {
return fmt.Errorf("deleting env vars: %w", err)
}
for _, p := range pairs {
_, err = tx.ExecContext(ctx,
"INSERT INTO app_env_vars (app_id, key, value) VALUES (?, ?, ?)",
appID, p.Key, p.Value,
)
if err != nil {
return fmt.Errorf("inserting env var %q: %w", p.Key, err)
}
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("committing transaction: %w", err)
}
return nil
}

View File

@@ -1,4 +1,3 @@
//nolint:dupl // Active Record pattern - similar structure to env_var.go is intentional
package models package models
import ( import (

View File

@@ -73,11 +73,30 @@ document.addEventListener("alpine:init", () => {
body: JSON.stringify( body: JSON.stringify(
this.vars.map((e) => ({ key: e.key, value: e.value })), this.vars.map((e) => ({ key: e.key, value: e.value })),
), ),
}).then((res) => { })
if (res.ok) { .then((res) => {
window.location.reload(); if (res.ok) {
} window.location.reload();
}); return;
}
res.json()
.then((data) => {
window.alert(
data.error ||
"Failed to save environment variables.",
);
})
.catch(() => {
window.alert(
"Failed to save environment variables.",
);
});
})
.catch(() => {
window.alert(
"Network error: could not save environment variables.",
);
});
}, },
})); }));