package handlers import ( "encoding/json" "net/http" "github.com/go-chi/chi/v5" "xorm.io/xorm" "github.com/forgeo/forgebucket/internal/api/middleware" "github.com/forgeo/forgebucket/internal/models" ) type SecretHandler struct { db *xorm.Engine sessionSecret string } func NewSecretHandler(db *xorm.Engine, sessionSecret string) *SecretHandler { return &SecretHandler{db: db, sessionSecret: sessionSecret} } // ── Secret list response (names only — never values) ───────────────────────── type secretListItem struct { ID int64 `json:"id"` Name string `json:"name"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } func toListItem(s models.Secret) secretListItem { return secretListItem{ ID: s.ID, Name: s.Name, CreatedAt: s.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: s.UpdatedAt.Format("2006-01-02T15:04:05Z"), } } // ── Repo secrets ────────────────────────────────────────────────────────────── func (h *SecretHandler) ListRepoSecrets(w http.ResponseWriter, r *http.Request) { repoID, ok := h.resolveRepoID(w, r) if !ok { return } h.listSecrets(w, models.SecretScopeRepo, repoID) } func (h *SecretHandler) UpsertRepoSecret(w http.ResponseWriter, r *http.Request) { repoID, ok := h.resolveRepoID(w, r) if !ok { return } h.upsertSecret(w, r, models.SecretScopeRepo, repoID) } func (h *SecretHandler) DeleteRepoSecret(w http.ResponseWriter, r *http.Request) { repoID, ok := h.resolveRepoID(w, r) if !ok { return } h.deleteSecret(w, r, models.SecretScopeRepo, repoID) } // ── Env secrets ─────────────────────────────────────────────────────────────── func (h *SecretHandler) ListEnvSecrets(w http.ResponseWriter, r *http.Request) { envID, ok := h.resolveEnvID(w, r) if !ok { return } h.listSecrets(w, models.SecretScopeEnv, envID) } func (h *SecretHandler) UpsertEnvSecret(w http.ResponseWriter, r *http.Request) { envID, ok := h.resolveEnvID(w, r) if !ok { return } h.upsertSecret(w, r, models.SecretScopeEnv, envID) } func (h *SecretHandler) DeleteEnvSecret(w http.ResponseWriter, r *http.Request) { envID, ok := h.resolveEnvID(w, r) if !ok { return } h.deleteSecret(w, r, models.SecretScopeEnv, envID) } // ── Workspace secrets ───────────────────────────────────────────────────────── func (h *SecretHandler) ListWorkspaceSecrets(w http.ResponseWriter, r *http.Request) { wsID, ok := h.resolveWorkspaceID(w, r) if !ok { return } h.listSecrets(w, models.SecretScopeWorkspace, wsID) } func (h *SecretHandler) UpsertWorkspaceSecret(w http.ResponseWriter, r *http.Request) { wsID, ok := h.resolveWorkspaceID(w, r) if !ok { return } h.upsertSecret(w, r, models.SecretScopeWorkspace, wsID) } func (h *SecretHandler) DeleteWorkspaceSecret(w http.ResponseWriter, r *http.Request) { wsID, ok := h.resolveWorkspaceID(w, r) if !ok { return } h.deleteSecret(w, r, models.SecretScopeWorkspace, wsID) } // ── Global secrets (admin only) ─────────────────────────────────────────────── func (h *SecretHandler) ListGlobalSecrets(w http.ResponseWriter, r *http.Request) { if !h.requireAdmin(w, r) { return } h.listSecrets(w, models.SecretScopeGlobal, 0) } func (h *SecretHandler) UpsertGlobalSecret(w http.ResponseWriter, r *http.Request) { if !h.requireAdmin(w, r) { return } h.upsertSecret(w, r, models.SecretScopeGlobal, 0) } func (h *SecretHandler) DeleteGlobalSecret(w http.ResponseWriter, r *http.Request) { if !h.requireAdmin(w, r) { return } h.deleteSecret(w, r, models.SecretScopeGlobal, 0) } // ── Shared CRUD primitives ──────────────────────────────────────────────────── func (h *SecretHandler) listSecrets(w http.ResponseWriter, scope models.SecretScope, scopeID int64) { var secrets []models.Secret h.db.Where("scope = ? AND scope_id = ?", scope, scopeID).Asc("name").Find(&secrets) items := make([]secretListItem, len(secrets)) for i, s := range secrets { items[i] = toListItem(s) } jsonOK(w, items) } func (h *SecretHandler) upsertSecret(w http.ResponseWriter, r *http.Request, scope models.SecretScope, scopeID int64) { var body struct { Name string `json:"name"` Value string `json:"value"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonError(w, "invalid request body", http.StatusBadRequest) return } if body.Name == "" || body.Value == "" { jsonError(w, "name and value are required", http.StatusBadRequest) return } encrypted, err := models.EncryptSecret(body.Value, h.sessionSecret) if err != nil { jsonError(w, "encryption failed", http.StatusInternalServerError) return } // Upsert: update if the name already exists for this scope. var existing models.Secret found, _ := h.db.Where("scope = ? AND scope_id = ? AND name = ?", scope, scopeID, body.Name).Get(&existing) if found { existing.EncryptedValue = encrypted h.db.ID(existing.ID).Cols("encrypted_value", "updated_at").Update(&existing) //nolint:errcheck jsonOK(w, toListItem(existing)) return } secret := &models.Secret{ Scope: scope, ScopeID: scopeID, Name: body.Name, EncryptedValue: encrypted, } if _, err := h.db.Insert(secret); err != nil { jsonError(w, "could not save secret", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(toListItem(*secret)) //nolint:errcheck } func (h *SecretHandler) deleteSecret(w http.ResponseWriter, r *http.Request, scope models.SecretScope, scopeID int64) { name := chi.URLParam(r, "name") res, err := h.db.Where("scope = ? AND scope_id = ? AND name = ?", scope, scopeID, name). Delete(&models.Secret{}) if err != nil || res == 0 { jsonError(w, "secret not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } // ── ResolveSecretsForRun ────────────────────────────────────────────────────── // Used by the CI executor to build the env var map for a pipeline job. // Priority order (highest wins): Env > Repo > Workspace > Global. func ResolveSecretsForRun(db *xorm.Engine, repoID, workspaceID, envID int64, sessionSecret string) map[string]string { out := map[string]string{} loadScope := func(scope models.SecretScope, scopeID int64) { var secrets []models.Secret db.Where("scope = ? AND scope_id = ?", scope, scopeID).Find(&secrets) for _, s := range secrets { if _, already := out[s.Name]; !already { pt, err := models.DecryptSecret(s.EncryptedValue, sessionSecret) if err == nil { out[s.Name] = pt } } } } // Load in reverse priority so higher-priority scopes overwrite. loadScope(models.SecretScopeGlobal, 0) if workspaceID != 0 { loadScope(models.SecretScopeWorkspace, workspaceID) } loadScope(models.SecretScopeRepo, repoID) if envID != 0 { loadScope(models.SecretScopeEnv, envID) } return out } // ── Helpers ─────────────────────────────────────────────────────────────────── func (h *SecretHandler) resolveRepoID(w http.ResponseWriter, r *http.Request) (int64, bool) { owner := chi.URLParam(r, "owner") repoName := chi.URLParam(r, "repo") var u models.User if found, _ := h.db.Where("username = ?", owner).Get(&u); !found { // Try workspace var ws models.Workspace if found2, _ := h.db.Where("handle = ?", owner).Get(&ws); !found2 { jsonError(w, "repository not found", http.StatusNotFound) return 0, false } var repo models.Repository if found3, _ := h.db.Where("workspace_id = ? AND name = ?", ws.ID, repoName).Get(&repo); !found3 { jsonError(w, "repository not found", http.StatusNotFound) return 0, false } return repo.ID, true } var repo models.Repository if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found { jsonError(w, "repository not found", http.StatusNotFound) return 0, false } return repo.ID, true } func (h *SecretHandler) resolveEnvID(w http.ResponseWriter, r *http.Request) (int64, bool) { repoID, ok := h.resolveRepoID(w, r) if !ok { return 0, false } envName := chi.URLParam(r, "envName") var env models.Environment if found, _ := h.db.Where("repo_id = ? AND name = ?", repoID, envName).Get(&env); !found { jsonError(w, "environment not found", http.StatusNotFound) return 0, false } return env.ID, true } func (h *SecretHandler) resolveWorkspaceID(w http.ResponseWriter, r *http.Request) (int64, bool) { handle := chi.URLParam(r, "handle") var ws models.Workspace if found, _ := h.db.Where("handle = ?", handle).Get(&ws); !found { jsonError(w, "workspace not found", http.StatusNotFound) return 0, false } // Require membership. userID, _ := middleware.UserIDFromContext(r.Context()) var member models.WorkspaceMember if found, _ := h.db.Where("workspace_id = ? AND user_id = ?", ws.ID, userID).Get(&member); !found { jsonError(w, "workspace not found", http.StatusNotFound) return 0, false } return ws.ID, true } func (h *SecretHandler) requireAdmin(w http.ResponseWriter, r *http.Request) bool { userID, _ := middleware.UserIDFromContext(r.Context()) var u models.User if found, _ := h.db.ID(userID).Get(&u); !found || !u.IsAdmin { jsonError(w, "admin required", http.StatusForbidden) return false } return true }