Go · 8255 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package repo
4
5 import (
6 "errors"
7 "net/http"
8 "strings"
9
10 "github.com/go-chi/chi/v5"
11
12 "github.com/tenseleyFlow/shithub/internal/actions/secrets"
13 actionsvars "github.com/tenseleyFlow/shithub/internal/actions/variables"
14 "github.com/tenseleyFlow/shithub/internal/auth/audit"
15 "github.com/tenseleyFlow/shithub/internal/auth/policy"
16 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
17 "github.com/tenseleyFlow/shithub/internal/web/middleware"
18 )
19
20 // MountSettingsActions registers the Actions secrets + variables settings
21 // routes. Caller wraps with RequireUser; per-route policy gates inside.
22 func (h *Handlers) MountSettingsActions(r chi.Router) {
23 r.Get("/{owner}/{repo}/settings/secrets/actions", h.settingsActionsSecrets)
24 r.Post("/{owner}/{repo}/settings/secrets/actions", h.settingsActionsSecretSet)
25 r.Post("/{owner}/{repo}/settings/secrets/actions/{name}/delete", h.settingsActionsSecretDelete)
26 r.Get("/{owner}/{repo}/settings/variables/actions", h.settingsActionsVariables)
27 r.Post("/{owner}/{repo}/settings/variables/actions", h.settingsActionsVariableSet)
28 r.Post("/{owner}/{repo}/settings/variables/actions/{name}/delete", h.settingsActionsVariableDelete)
29 }
30
31 func (h *Handlers) settingsActionsSecrets(w http.ResponseWriter, r *http.Request) {
32 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
33 if !ok {
34 return
35 }
36 h.renderRepoActionsSettings(w, r, row, owner.Username, "secrets", "", settingsNoticeMessage(r.URL.Query().Get("notice")))
37 }
38
39 func (h *Handlers) settingsActionsSecretSet(w http.ResponseWriter, r *http.Request) {
40 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
41 if !ok {
42 return
43 }
44 if h.d.SecretBox == nil {
45 http.Error(w, "actions secret key not configured", http.StatusServiceUnavailable)
46 return
47 }
48 if err := r.ParseForm(); err != nil {
49 http.Error(w, "form parse", http.StatusBadRequest)
50 return
51 }
52 viewer := middleware.CurrentUserFromContext(r.Context())
53 name := strings.TrimSpace(r.PostFormValue("name"))
54 value := []byte(r.PostFormValue("value"))
55 err := secrets.Deps{Pool: h.d.Pool, Box: h.d.SecretBox}.Set(r.Context(), secrets.RepoScope(row.ID), name, value, viewer.ID)
56 if err != nil {
57 h.renderRepoActionsSettings(w, r, row, owner.Username, "secrets", friendlyActionsSecretError(err), "")
58 return
59 }
60 h.recordRepoActionsAudit(r, viewer, audit.ActionActionsSecretSet, row.ID, name)
61 http.Redirect(w, r, repoActionsSettingsPath(owner.Username, row.Name, "secrets")+"?notice=saved", http.StatusSeeOther)
62 }
63
64 func (h *Handlers) settingsActionsSecretDelete(w http.ResponseWriter, r *http.Request) {
65 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
66 if !ok {
67 return
68 }
69 name := chi.URLParam(r, "name")
70 err := secrets.Deps{Pool: h.d.Pool, Box: h.d.SecretBox}.Delete(r.Context(), secrets.RepoScope(row.ID), name)
71 if err != nil {
72 if errors.Is(err, secrets.ErrInvalidName) {
73 http.Error(w, "bad secret name", http.StatusBadRequest)
74 return
75 }
76 http.Error(w, "delete failed", http.StatusInternalServerError)
77 return
78 }
79 viewer := middleware.CurrentUserFromContext(r.Context())
80 h.recordRepoActionsAudit(r, viewer, audit.ActionActionsSecretDeleted, row.ID, name)
81 http.Redirect(w, r, repoActionsSettingsPath(owner.Username, row.Name, "secrets")+"?notice=deleted", http.StatusSeeOther)
82 }
83
84 func (h *Handlers) settingsActionsVariables(w http.ResponseWriter, r *http.Request) {
85 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
86 if !ok {
87 return
88 }
89 h.renderRepoActionsSettings(w, r, row, owner.Username, "variables", "", settingsNoticeMessage(r.URL.Query().Get("notice")))
90 }
91
92 func (h *Handlers) settingsActionsVariableSet(w http.ResponseWriter, r *http.Request) {
93 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
94 if !ok {
95 return
96 }
97 if err := r.ParseForm(); err != nil {
98 http.Error(w, "form parse", http.StatusBadRequest)
99 return
100 }
101 viewer := middleware.CurrentUserFromContext(r.Context())
102 name := strings.TrimSpace(r.PostFormValue("name"))
103 value := r.PostFormValue("value")
104 err := actionsvars.Deps{Pool: h.d.Pool}.Set(r.Context(), actionsvars.RepoScope(row.ID), name, value, viewer.ID)
105 if err != nil {
106 h.renderRepoActionsSettings(w, r, row, owner.Username, "variables", friendlyActionsVariableError(err), "")
107 return
108 }
109 h.recordRepoActionsAudit(r, viewer, audit.ActionActionsVariableSet, row.ID, name)
110 http.Redirect(w, r, repoActionsSettingsPath(owner.Username, row.Name, "variables")+"?notice=saved", http.StatusSeeOther)
111 }
112
113 func (h *Handlers) settingsActionsVariableDelete(w http.ResponseWriter, r *http.Request) {
114 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoSettingsActions)
115 if !ok {
116 return
117 }
118 name := chi.URLParam(r, "name")
119 err := actionsvars.Deps{Pool: h.d.Pool}.Delete(r.Context(), actionsvars.RepoScope(row.ID), name)
120 if err != nil {
121 if errors.Is(err, actionsvars.ErrInvalidName) {
122 http.Error(w, "bad variable name", http.StatusBadRequest)
123 return
124 }
125 http.Error(w, "delete failed", http.StatusInternalServerError)
126 return
127 }
128 viewer := middleware.CurrentUserFromContext(r.Context())
129 h.recordRepoActionsAudit(r, viewer, audit.ActionActionsVariableDeleted, row.ID, name)
130 http.Redirect(w, r, repoActionsSettingsPath(owner.Username, row.Name, "variables")+"?notice=deleted", http.StatusSeeOther)
131 }
132
133 func (h *Handlers) renderRepoActionsSettings(w http.ResponseWriter, r *http.Request, row reposdb.Repo, owner, kind, errMsg, notice string) {
134 data := map[string]any{
135 "CSRFToken": middleware.CSRFTokenForRequest(r),
136 "Owner": owner,
137 "Repo": row,
138 "Kind": kind,
139 "Error": errMsg,
140 "Notice": notice,
141 }
142 switch kind {
143 case "secrets":
144 items, err := secrets.Deps{Pool: h.d.Pool, Box: h.d.SecretBox}.List(r.Context(), secrets.RepoScope(row.ID))
145 if err != nil {
146 h.d.Logger.WarnContext(r.Context(), "actions secrets: list repo", "repo_id", row.ID, "error", err)
147 items = nil
148 }
149 data["Title"] = "Actions secrets · " + row.Name
150 data["Heading"] = "Actions secrets"
151 data["IsSecrets"] = true
152 data["Secrets"] = items
153 data["SecretDisabled"] = h.d.SecretBox == nil
154 data["SettingsActive"] = "actions-secrets"
155 data["FormAction"] = repoActionsSettingsPath(owner, row.Name, "secrets")
156 case "variables":
157 items, err := actionsvars.Deps{Pool: h.d.Pool}.List(r.Context(), actionsvars.RepoScope(row.ID))
158 if err != nil {
159 h.d.Logger.WarnContext(r.Context(), "actions variables: list repo", "repo_id", row.ID, "error", err)
160 items = nil
161 }
162 data["Title"] = "Actions variables · " + row.Name
163 data["Heading"] = "Actions variables"
164 data["Variables"] = items
165 data["SettingsActive"] = "actions-variables"
166 data["FormAction"] = repoActionsSettingsPath(owner, row.Name, "variables")
167 }
168 h.d.Render.RenderPage(w, r, "repo/settings_secrets", data)
169 }
170
171 func repoActionsSettingsPath(owner, repoName, kind string) string {
172 return "/" + owner + "/" + repoName + "/settings/" + kind + "/actions"
173 }
174
175 func friendlyActionsSecretError(err error) string {
176 switch {
177 case errors.Is(err, secrets.ErrInvalidName):
178 return "Name must start with a letter or underscore and contain only letters, numbers, and underscores."
179 case errors.Is(err, secrets.ErrEmptyValue):
180 return "Secret value is required."
181 case errors.Is(err, secrets.ErrInvalidScope):
182 return "Invalid secret scope."
183 default:
184 return "Could not save secret."
185 }
186 }
187
188 func friendlyActionsVariableError(err error) string {
189 switch {
190 case errors.Is(err, actionsvars.ErrInvalidName):
191 return "Name must start with a letter or underscore and contain only letters, numbers, and underscores."
192 case errors.Is(err, actionsvars.ErrValueTooLong):
193 return "Variable value must be 4096 characters or fewer."
194 case errors.Is(err, actionsvars.ErrInvalidScope):
195 return "Invalid variable scope."
196 default:
197 return "Could not save variable."
198 }
199 }
200
201 func (h *Handlers) recordRepoActionsAudit(r *http.Request, viewer middleware.CurrentUser, action audit.Action, repoID int64, name string) {
202 actor, meta := viewer.AuditActor(map[string]any{"name": name})
203 _ = h.d.Audit.Record(r.Context(), h.d.Pool, actor, action, audit.TargetRepo, repoID, meta)
204 }
205