tenseleyflow/shithub / 66b8d75

Browse files

S33: per-repo webhook CRUD + delivery handlers

Authored by espadonne
SHA
66b8d7516a16c140d715c9337f1ccfbd7854e74c
Parents
1bd7d1d
Tree
4df6c77

1 changed file

StatusFile+-
A internal/web/handlers/repo/webhooks.go 411 0
internal/web/handlers/repo/webhooks.goadded
@@ -0,0 +1,411 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"encoding/json"
7
+	"errors"
8
+	"net/http"
9
+	"strconv"
10
+	"strings"
11
+
12
+	"github.com/go-chi/chi/v5"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
15
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
16
+	"github.com/tenseleyFlow/shithub/internal/webhook"
17
+	webhookdb "github.com/tenseleyFlow/shithub/internal/webhook/sqlc"
18
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
19
+)
20
+
21
+// MountWebhooks registers the per-repo webhook CRUD + delivery views.
22
+// Caller wraps with RequireUser; per-route policy gates inside.
23
+//
24
+// When the SecretBox isn't configured (operator hasn't set the
25
+// AEAD key) the routes still register, but every handler short-
26
+// circuits to a placeholder explaining the misconfiguration —
27
+// staying consistent with the S32-shipped placeholder shape.
28
+func (h *Handlers) MountWebhooks(r chi.Router) {
29
+	r.Get("/{owner}/{repo}/settings/webhooks", h.webhooksList)
30
+	r.Get("/{owner}/{repo}/settings/webhooks/new", h.webhookNewForm)
31
+	r.Post("/{owner}/{repo}/settings/webhooks", h.webhookCreate)
32
+	r.Get("/{owner}/{repo}/settings/webhooks/{id}", h.webhookEditForm)
33
+	r.Post("/{owner}/{repo}/settings/webhooks/{id}", h.webhookUpdate)
34
+	r.Post("/{owner}/{repo}/settings/webhooks/{id}/delete", h.webhookDelete)
35
+	r.Post("/{owner}/{repo}/settings/webhooks/{id}/toggle", h.webhookToggle)
36
+	r.Post("/{owner}/{repo}/settings/webhooks/{id}/ping", h.webhookPing)
37
+	r.Get("/{owner}/{repo}/settings/webhooks/{id}/deliveries/{deliveryID}", h.webhookDeliveryView)
38
+	r.Post("/{owner}/{repo}/settings/webhooks/{id}/deliveries/{deliveryID}/redeliver", h.webhookRedeliver)
39
+}
40
+
41
+// webhooksList renders the per-repo webhook list. Replaces the S32
42
+// placeholder.
43
+func (h *Handlers) webhooksList(w http.ResponseWriter, r *http.Request) {
44
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
45
+	if !ok {
46
+		return
47
+	}
48
+	if h.d.SecretBox == nil {
49
+		h.renderWebhookPlaceholder(w, r, row, owner.Username, "Webhook delivery requires the at-rest secret key. Set Auth.TOTPKeyB64 in config and restart.")
50
+		return
51
+	}
52
+	hooks, err := webhookdb.New().ListWebhooksForOwner(r.Context(), h.d.Pool, webhookdb.ListWebhooksForOwnerParams{
53
+		OwnerKind: webhookdb.WebhookOwnerKindRepo,
54
+		OwnerID:   row.ID,
55
+	})
56
+	if err != nil {
57
+		h.d.Logger.WarnContext(r.Context(), "webhooks: list", "error", err)
58
+		hooks = nil
59
+	}
60
+	notice := r.URL.Query().Get("notice")
61
+	h.d.Render.RenderPage(w, r, "repo/settings_webhooks", map[string]any{
62
+		"Title":          "Webhooks · " + row.Name,
63
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
64
+		"Owner":          owner.Username,
65
+		"Repo":           row,
66
+		"Webhooks":       hooks,
67
+		"SettingsActive": "webhooks",
68
+		"Notice":         settingsNoticeMessage(notice),
69
+	})
70
+}
71
+
72
+// webhookNewForm renders the create form.
73
+func (h *Handlers) webhookNewForm(w http.ResponseWriter, r *http.Request) {
74
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
75
+	if !ok {
76
+		return
77
+	}
78
+	if h.d.SecretBox == nil {
79
+		h.renderWebhookPlaceholder(w, r, row, owner.Username, "Webhook delivery requires the at-rest secret key.")
80
+		return
81
+	}
82
+	h.renderWebhookForm(w, r, row, owner.Username, nil, "", "")
83
+}
84
+
85
+// webhookCreate persists a new webhook + emits a ping.
86
+func (h *Handlers) webhookCreate(w http.ResponseWriter, r *http.Request) {
87
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
88
+	if !ok {
89
+		return
90
+	}
91
+	if h.d.SecretBox == nil {
92
+		http.Error(w, "webhook key not configured", http.StatusServiceUnavailable)
93
+		return
94
+	}
95
+	if err := r.ParseForm(); err != nil {
96
+		http.Error(w, "form parse", http.StatusBadRequest)
97
+		return
98
+	}
99
+	viewer := middleware.CurrentUserFromContext(r.Context())
100
+	params := webhook.CreateParams{
101
+		OwnerKind:   "repo",
102
+		OwnerID:     row.ID,
103
+		URL:         strings.TrimSpace(r.PostFormValue("url")),
104
+		ContentType: pickContentType(r.PostFormValue("content_type")),
105
+		Events:      splitCommaList(r.PostFormValue("events")),
106
+		Secret:      strings.TrimSpace(r.PostFormValue("secret")),
107
+		Active:      r.PostFormValue("active") == "on",
108
+		SSL:         r.PostFormValue("ssl_verification") == "on" || r.PostFormValue("ssl_verification") == "",
109
+		ActorUserID: viewer.ID,
110
+	}
111
+	created, err := webhook.Create(r.Context(), webhook.ManageDeps{
112
+		Pool: h.d.Pool, SecretBox: h.d.SecretBox, SSRF: webhook.DefaultSSRFConfig(),
113
+	}, params)
114
+	if err != nil {
115
+		h.renderWebhookForm(w, r, row, owner.Username, &createFormState{
116
+			URL: params.URL, ContentType: string(params.OwnerKind), Events: params.Events,
117
+			Active: params.Active, SSL: params.SSL,
118
+		}, friendlyWebhookError(err), "")
119
+		return
120
+	}
121
+	_ = h.d.Audit.Record(r.Context(), h.d.Pool, viewer.ID,
122
+		audit.ActionRepoCreated, audit.TargetRepo, row.ID,
123
+		map[string]any{"action": "webhook_created", "webhook_id": created.ID, "url": params.URL})
124
+
125
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks?notice=saved", http.StatusSeeOther)
126
+}
127
+
128
+// webhookEditForm renders the edit form for one webhook.
129
+func (h *Handlers) webhookEditForm(w http.ResponseWriter, r *http.Request) {
130
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
131
+	if !ok {
132
+		return
133
+	}
134
+	hook, ok := h.loadOwnedWebhook(w, r, row.ID)
135
+	if !ok {
136
+		return
137
+	}
138
+	deliveries, _ := webhookdb.New().ListDeliveriesForWebhook(r.Context(), h.d.Pool, webhookdb.ListDeliveriesForWebhookParams{
139
+		WebhookID: hook.ID, Limit: 50,
140
+	})
141
+	h.d.Render.RenderPage(w, r, "repo/settings_webhook_edit", map[string]any{
142
+		"Title":          hook.Url + " · webhook",
143
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
144
+		"Owner":          owner.Username,
145
+		"Repo":           row,
146
+		"Webhook":        hook,
147
+		"EventsCSV":      strings.Join(hook.Events, ", "),
148
+		"Deliveries":     deliveries,
149
+		"SettingsActive": "webhooks",
150
+	})
151
+}
152
+
153
+// webhookUpdate persists an edit.
154
+func (h *Handlers) webhookUpdate(w http.ResponseWriter, r *http.Request) {
155
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
156
+	if !ok {
157
+		return
158
+	}
159
+	hook, ok := h.loadOwnedWebhook(w, r, row.ID)
160
+	if !ok {
161
+		return
162
+	}
163
+	if err := r.ParseForm(); err != nil {
164
+		http.Error(w, "form parse", http.StatusBadRequest)
165
+		return
166
+	}
167
+	params := webhook.UpdateParams{
168
+		URL:         strings.TrimSpace(r.PostFormValue("url")),
169
+		ContentType: pickContentType(r.PostFormValue("content_type")),
170
+		Events:      splitCommaList(r.PostFormValue("events")),
171
+		Active:      r.PostFormValue("active") == "on",
172
+		SSL:         r.PostFormValue("ssl_verification") == "on" || r.PostFormValue("ssl_verification") == "",
173
+		NewSecret:   strings.TrimSpace(r.PostFormValue("new_secret")),
174
+	}
175
+	if err := webhook.Update(r.Context(), webhook.ManageDeps{
176
+		Pool: h.d.Pool, SecretBox: h.d.SecretBox, SSRF: webhook.DefaultSSRFConfig(),
177
+	}, hook.ID, params); err != nil {
178
+		http.Error(w, friendlyWebhookError(err), http.StatusBadRequest)
179
+		return
180
+	}
181
+	viewer := middleware.CurrentUserFromContext(r.Context())
182
+	_ = h.d.Audit.Record(r.Context(), h.d.Pool, viewer.ID,
183
+		audit.ActionRepoCreated, audit.TargetRepo, row.ID,
184
+		map[string]any{"action": "webhook_updated", "webhook_id": hook.ID})
185
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks/"+strconv.FormatInt(hook.ID, 10)+"?notice=saved", http.StatusSeeOther)
186
+}
187
+
188
+// webhookDelete drops a webhook + cascades its deliveries.
189
+func (h *Handlers) webhookDelete(w http.ResponseWriter, r *http.Request) {
190
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
191
+	if !ok {
192
+		return
193
+	}
194
+	hook, ok := h.loadOwnedWebhook(w, r, row.ID)
195
+	if !ok {
196
+		return
197
+	}
198
+	if err := webhook.Delete(r.Context(), webhook.ManageDeps{
199
+		Pool: h.d.Pool, SecretBox: h.d.SecretBox,
200
+	}, hook.ID); err != nil {
201
+		http.Error(w, "delete failed", http.StatusInternalServerError)
202
+		return
203
+	}
204
+	viewer := middleware.CurrentUserFromContext(r.Context())
205
+	_ = h.d.Audit.Record(r.Context(), h.d.Pool, viewer.ID,
206
+		audit.ActionRepoCreated, audit.TargetRepo, row.ID,
207
+		map[string]any{"action": "webhook_deleted", "webhook_id": hook.ID})
208
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks?notice=saved", http.StatusSeeOther)
209
+}
210
+
211
+// webhookToggle flips active true⇄false. Re-enabling a previously
212
+// auto-disabled webhook resets the failure counter via SetActive.
213
+func (h *Handlers) webhookToggle(w http.ResponseWriter, r *http.Request) {
214
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
215
+	if !ok {
216
+		return
217
+	}
218
+	hook, ok := h.loadOwnedWebhook(w, r, row.ID)
219
+	if !ok {
220
+		return
221
+	}
222
+	if err := webhook.SetActive(r.Context(), webhook.ManageDeps{
223
+		Pool: h.d.Pool, SecretBox: h.d.SecretBox,
224
+	}, hook.ID, !hook.Active); err != nil {
225
+		http.Error(w, "toggle failed", http.StatusInternalServerError)
226
+		return
227
+	}
228
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks/"+strconv.FormatInt(hook.ID, 10)+"?notice=saved", http.StatusSeeOther)
229
+}
230
+
231
+// webhookPing enqueues a synthetic ping delivery.
232
+func (h *Handlers) webhookPing(w http.ResponseWriter, r *http.Request) {
233
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
234
+	if !ok {
235
+		return
236
+	}
237
+	hook, ok := h.loadOwnedWebhook(w, r, row.ID)
238
+	if !ok {
239
+		return
240
+	}
241
+	if err := webhook.EnqueuePing(r.Context(), webhook.FanoutDeps{
242
+		Pool: h.d.Pool, Logger: h.d.Logger,
243
+	}, hook.ID); err != nil {
244
+		h.d.Logger.WarnContext(r.Context(), "webhook ping", "error", err)
245
+	}
246
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks/"+strconv.FormatInt(hook.ID, 10)+"?notice=saved", http.StatusSeeOther)
247
+}
248
+
249
+// webhookDeliveryView shows one delivery's request/response.
250
+func (h *Handlers) webhookDeliveryView(w http.ResponseWriter, r *http.Request) {
251
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
252
+	if !ok {
253
+		return
254
+	}
255
+	hook, ok := h.loadOwnedWebhook(w, r, row.ID)
256
+	if !ok {
257
+		return
258
+	}
259
+	deliveryID, err := strconv.ParseInt(chi.URLParam(r, "deliveryID"), 10, 64)
260
+	if err != nil || deliveryID <= 0 {
261
+		http.Error(w, "bad delivery id", http.StatusBadRequest)
262
+		return
263
+	}
264
+	delivery, err := webhookdb.New().GetDeliveryByID(r.Context(), h.d.Pool, deliveryID)
265
+	if err != nil || delivery.WebhookID != hook.ID {
266
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
267
+		return
268
+	}
269
+	h.d.Render.RenderPage(w, r, "repo/settings_webhook_delivery", map[string]any{
270
+		"Title":          "Delivery #" + strconv.FormatInt(delivery.ID, 10),
271
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
272
+		"Owner":          owner.Username,
273
+		"Repo":           row,
274
+		"Webhook":        hook,
275
+		"Delivery":       delivery,
276
+		"PayloadPretty":  prettyJSON(delivery.Payload),
277
+		"ResponseBody":   string(delivery.ResponseBody),
278
+		"SettingsActive": "webhooks",
279
+	})
280
+}
281
+
282
+// webhookRedeliver clones a past delivery and enqueues it.
283
+func (h *Handlers) webhookRedeliver(w http.ResponseWriter, r *http.Request) {
284
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoAdmin)
285
+	if !ok {
286
+		return
287
+	}
288
+	hook, ok := h.loadOwnedWebhook(w, r, row.ID)
289
+	if !ok {
290
+		return
291
+	}
292
+	originalID, err := strconv.ParseInt(chi.URLParam(r, "deliveryID"), 10, 64)
293
+	if err != nil || originalID <= 0 {
294
+		http.Error(w, "bad delivery id", http.StatusBadRequest)
295
+		return
296
+	}
297
+	// Defense in depth: confirm the delivery belongs to this webhook.
298
+	orig, err := webhookdb.New().GetDeliveryByID(r.Context(), h.d.Pool, originalID)
299
+	if err != nil || orig.WebhookID != hook.ID {
300
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
301
+		return
302
+	}
303
+	newID, err := webhook.Redeliver(r.Context(), webhook.FanoutDeps{
304
+		Pool: h.d.Pool, Logger: h.d.Logger,
305
+	}, originalID)
306
+	if err != nil {
307
+		http.Error(w, "redeliver failed", http.StatusInternalServerError)
308
+		return
309
+	}
310
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/settings/webhooks/"+strconv.FormatInt(hook.ID, 10)+"/deliveries/"+strconv.FormatInt(newID, 10), http.StatusSeeOther)
311
+}
312
+
313
+// loadOwnedWebhook resolves the URL `id` param and confirms it
314
+// belongs to this repo. Writes 404 + returns false on miss.
315
+func (h *Handlers) loadOwnedWebhook(w http.ResponseWriter, r *http.Request, repoID int64) (webhookdb.Webhook, bool) {
316
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
317
+	if err != nil || id <= 0 {
318
+		http.Error(w, "bad id", http.StatusBadRequest)
319
+		return webhookdb.Webhook{}, false
320
+	}
321
+	hook, err := webhookdb.New().GetWebhookByID(r.Context(), h.d.Pool, id)
322
+	if err != nil {
323
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
324
+		return webhookdb.Webhook{}, false
325
+	}
326
+	if hook.OwnerKind != webhookdb.WebhookOwnerKindRepo || hook.OwnerID != repoID {
327
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
328
+		return webhookdb.Webhook{}, false
329
+	}
330
+	return hook, true
331
+}
332
+
333
+// renderWebhookForm renders the create form (state may carry repopulated
334
+// fields after a validation failure).
335
+type createFormState struct {
336
+	URL         string
337
+	ContentType string
338
+	Events      []string
339
+	Active      bool
340
+	SSL         bool
341
+}
342
+
343
+func (h *Handlers) renderWebhookForm(w http.ResponseWriter, r *http.Request, repo any, owner string, state *createFormState, errMsg string, notice string) {
344
+	if state == nil {
345
+		state = &createFormState{Active: true, SSL: true, ContentType: "json"}
346
+	}
347
+	h.d.Render.RenderPage(w, r, "repo/settings_webhook_new", map[string]any{
348
+		"Title":          "New webhook",
349
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
350
+		"Owner":          owner,
351
+		"Repo":           repo,
352
+		"Form":           state,
353
+		"EventsCSV":      strings.Join(state.Events, ", "),
354
+		"Error":          errMsg,
355
+		"Notice":         notice,
356
+		"SettingsActive": "webhooks",
357
+	})
358
+}
359
+
360
+// renderWebhookPlaceholder reuses the deferred-tab template from S32
361
+// when the operator hasn't configured the AEAD key.
362
+func (h *Handlers) renderWebhookPlaceholder(w http.ResponseWriter, r *http.Request, repo any, owner, body string) {
363
+	h.d.Render.RenderPage(w, r, "repo/settings_placeholder", map[string]any{
364
+		"Title":          "Webhooks",
365
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
366
+		"Owner":          owner,
367
+		"Repo":           repo,
368
+		"Heading":        "Webhooks",
369
+		"Body":           body,
370
+		"SettingsActive": "webhooks",
371
+	})
372
+}
373
+
374
+// pickContentType narrows the form input to the enum's two options.
375
+func pickContentType(s string) string {
376
+	switch strings.TrimSpace(s) {
377
+	case "form":
378
+		return "form"
379
+	default:
380
+		return "json"
381
+	}
382
+}
383
+
384
+// friendlyWebhookError maps webhook orchestrator errors to user-facing
385
+// strings. Falls back to the raw error so the operator gets something
386
+// actionable in the form.
387
+func friendlyWebhookError(err error) string {
388
+	switch {
389
+	case errors.Is(err, webhook.ErrBadURL):
390
+		return "URL must be http or https with a host."
391
+	case errors.Is(err, webhook.ErrBadContentType):
392
+		return "Content type must be json or form."
393
+	case errors.Is(err, webhook.ErrBadEvent):
394
+		return "Event names must be 1–64 lowercase characters."
395
+	}
396
+	return err.Error()
397
+}
398
+
399
+// prettyJSON re-indents a JSON document so the delivery view shows it
400
+// readably. Fall through to the raw bytes on parse failure.
401
+func prettyJSON(raw []byte) string {
402
+	var v any
403
+	if err := json.Unmarshal(raw, &v); err != nil {
404
+		return string(raw)
405
+	}
406
+	out, err := json.MarshalIndent(v, "", "  ")
407
+	if err != nil {
408
+		return string(raw)
409
+	}
410
+	return string(out)
411
+}