tenseleyflow/shithub / 7bfdb60

Browse files

S33: manage orchestrator — Create/Update/Delete/SetActive

Authored by espadonne
SHA
7bfdb603a4f7317ff6d0319eb44a6ef0d99ae14e
Parents
3dd5a86
Tree
2d79de3

2 changed files

StatusFile+-
A internal/webhook/manage.go 238 0
A internal/webhook/manage_test.go 53 0
internal/webhook/manage.goadded
@@ -0,0 +1,238 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package webhook
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"net/url"
9
+
10
+	"github.com/jackc/pgx/v5/pgtype"
11
+	"github.com/jackc/pgx/v5/pgxpool"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/auth/secretbox"
14
+	webhookdb "github.com/tenseleyFlow/shithub/internal/webhook/sqlc"
15
+)
16
+
17
+// ManageDeps wires the create/update/delete orchestrator.
18
+type ManageDeps struct {
19
+	Pool      *pgxpool.Pool
20
+	SecretBox *secretbox.Box
21
+	SSRF      SSRFConfig
22
+}
23
+
24
+// CreateParams is the create-form input. Empty Secret means "mint
25
+// one"; a non-empty value comes through verbatim from the operator
26
+// (matches GitHub's behaviour where the user can paste their own).
27
+type CreateParams struct {
28
+	OwnerKind   string // "repo" or "org"
29
+	OwnerID     int64
30
+	URL         string
31
+	ContentType string // "json" or "form"
32
+	Events      []string
33
+	Secret      string
34
+	Active      bool
35
+	SSL         bool
36
+	ActorUserID int64
37
+}
38
+
39
+// CreateErrors surfaces user-friendly validation failures so handlers
40
+// can render them in-page.
41
+var (
42
+	ErrBadURL          = errors.New("webhook: URL must be http or https with a host")
43
+	ErrBadContentType  = errors.New("webhook: content_type must be json or form")
44
+	ErrBadOwnerKind    = errors.New("webhook: owner_kind must be repo or org")
45
+	ErrBadEvent        = errors.New("webhook: event names must be 1–64 lowercase chars")
46
+)
47
+
48
+// Create persists a new webhook + emits a synthetic ping delivery so
49
+// the operator sees an immediate round-trip. Returns the created row.
50
+func Create(ctx context.Context, deps ManageDeps, params CreateParams) (webhookdb.Webhook, error) {
51
+	if err := validateURL(params.URL); err != nil {
52
+		return webhookdb.Webhook{}, err
53
+	}
54
+	ownerKind, err := parseOwnerKind(params.OwnerKind)
55
+	if err != nil {
56
+		return webhookdb.Webhook{}, err
57
+	}
58
+	contentType, err := parseContentType(params.ContentType)
59
+	if err != nil {
60
+		return webhookdb.Webhook{}, err
61
+	}
62
+	events, err := normalizeEvents(params.Events)
63
+	if err != nil {
64
+		return webhookdb.Webhook{}, err
65
+	}
66
+	secret := params.Secret
67
+	if secret == "" {
68
+		secret, err = GenerateSecret()
69
+		if err != nil {
70
+			return webhookdb.Webhook{}, err
71
+		}
72
+	}
73
+	cipher, nonce, err := SealSecret(deps.SecretBox, secret)
74
+	if err != nil {
75
+		return webhookdb.Webhook{}, err
76
+	}
77
+	q := webhookdb.New()
78
+	row, err := q.CreateWebhook(ctx, deps.Pool, webhookdb.CreateWebhookParams{
79
+		OwnerKind:            ownerKind,
80
+		OwnerID:              params.OwnerID,
81
+		Url:                  params.URL,
82
+		ContentType:          contentType,
83
+		Events:               events,
84
+		SecretCiphertext:     cipher,
85
+		SecretNonce:          nonce,
86
+		Active:               params.Active,
87
+		SslVerification:      params.SSL,
88
+		AutoDisableThreshold: 50,
89
+		CreatedByUserID:      pgtype.Int8{Int64: params.ActorUserID, Valid: params.ActorUserID != 0},
90
+	})
91
+	if err != nil {
92
+		return webhookdb.Webhook{}, err
93
+	}
94
+	// Synthetic ping. Best-effort — failure to enqueue is logged at
95
+	// the caller, but we don't unwind the create.
96
+	_ = EnqueuePing(ctx, FanoutDeps{Pool: deps.Pool}, row.ID)
97
+	return row, nil
98
+}
99
+
100
+// UpdateParams mirrors the edit form.
101
+type UpdateParams struct {
102
+	URL         string
103
+	ContentType string
104
+	Events      []string
105
+	Active      bool
106
+	SSL         bool
107
+	NewSecret   string // when non-empty, rotate the secret
108
+}
109
+
110
+// Update applies a config change to a webhook. Returns ErrBadURL etc.
111
+// on validation failure.
112
+func Update(ctx context.Context, deps ManageDeps, hookID int64, params UpdateParams) error {
113
+	if err := validateURL(params.URL); err != nil {
114
+		return err
115
+	}
116
+	contentType, err := parseContentType(params.ContentType)
117
+	if err != nil {
118
+		return err
119
+	}
120
+	events, err := normalizeEvents(params.Events)
121
+	if err != nil {
122
+		return err
123
+	}
124
+	q := webhookdb.New()
125
+	if err := q.UpdateWebhook(ctx, deps.Pool, webhookdb.UpdateWebhookParams{
126
+		ID:                   hookID,
127
+		Url:                  params.URL,
128
+		ContentType:          contentType,
129
+		Events:               events,
130
+		Active:               params.Active,
131
+		SslVerification:      params.SSL,
132
+		AutoDisableThreshold: 50,
133
+	}); err != nil {
134
+		return err
135
+	}
136
+	if params.NewSecret != "" {
137
+		cipher, nonce, err := SealSecret(deps.SecretBox, params.NewSecret)
138
+		if err != nil {
139
+			return err
140
+		}
141
+		if err := q.UpdateWebhookSecret(ctx, deps.Pool, webhookdb.UpdateWebhookSecretParams{
142
+			ID: hookID, SecretCiphertext: cipher, SecretNonce: nonce,
143
+		}); err != nil {
144
+			return err
145
+		}
146
+	}
147
+	return nil
148
+}
149
+
150
+// Delete removes a webhook + cascades its deliveries (FK ON DELETE CASCADE).
151
+func Delete(ctx context.Context, deps ManageDeps, hookID int64) error {
152
+	return webhookdb.New().DeleteWebhook(ctx, deps.Pool, hookID)
153
+}
154
+
155
+// SetActive toggles the active flag, clearing any auto-disable state.
156
+func SetActive(ctx context.Context, deps ManageDeps, hookID int64, active bool) error {
157
+	return webhookdb.New().SetWebhookActive(ctx, deps.Pool, webhookdb.SetWebhookActiveParams{
158
+		ID: hookID, Active: active,
159
+	})
160
+}
161
+
162
+func parseOwnerKind(s string) (webhookdb.WebhookOwnerKind, error) {
163
+	switch s {
164
+	case "repo":
165
+		return webhookdb.WebhookOwnerKindRepo, nil
166
+	case "org":
167
+		return webhookdb.WebhookOwnerKindOrg, nil
168
+	}
169
+	return "", ErrBadOwnerKind
170
+}
171
+
172
+func parseContentType(s string) (webhookdb.WebhookContentType, error) {
173
+	switch s {
174
+	case "", "json":
175
+		return webhookdb.WebhookContentTypeJson, nil
176
+	case "form":
177
+		return webhookdb.WebhookContentTypeForm, nil
178
+	}
179
+	return "", ErrBadContentType
180
+}
181
+
182
+// normalizeEvents lowercases + dedups + length-checks. An empty input
183
+// = all events ([]string{}). The shape is left permissive for now;
184
+// tightening to a known-kinds enum is a post-MVP follow-up.
185
+func normalizeEvents(events []string) ([]string, error) {
186
+	seen := map[string]struct{}{}
187
+	out := []string{}
188
+	for _, e := range events {
189
+		e = lower(trim(e))
190
+		if e == "" {
191
+			continue
192
+		}
193
+		if len(e) > 64 {
194
+			return nil, ErrBadEvent
195
+		}
196
+		if _, dup := seen[e]; dup {
197
+			continue
198
+		}
199
+		seen[e] = struct{}{}
200
+		out = append(out, e)
201
+	}
202
+	return out, nil
203
+}
204
+
205
+func validateURL(raw string) error {
206
+	u, err := url.Parse(raw)
207
+	if err != nil {
208
+		return ErrBadURL
209
+	}
210
+	if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
211
+		return ErrBadURL
212
+	}
213
+	return nil
214
+}
215
+
216
+func trim(s string) string {
217
+	for len(s) > 0 && (s[0] == ' ' || s[0] == '\t' || s[0] == '\n' || s[0] == '\r') {
218
+		s = s[1:]
219
+	}
220
+	for len(s) > 0 {
221
+		c := s[len(s)-1]
222
+		if c != ' ' && c != '\t' && c != '\n' && c != '\r' {
223
+			break
224
+		}
225
+		s = s[:len(s)-1]
226
+	}
227
+	return s
228
+}
229
+
230
+func lower(s string) string {
231
+	b := []byte(s)
232
+	for i, c := range b {
233
+		if 'A' <= c && c <= 'Z' {
234
+			b[i] = c + ('a' - 'A')
235
+		}
236
+	}
237
+	return string(b)
238
+}
internal/webhook/manage_test.goadded
@@ -0,0 +1,53 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package webhook
4
+
5
+import (
6
+	"errors"
7
+	"strings"
8
+	"testing"
9
+)
10
+
11
+func TestNormalizeEvents(t *testing.T) {
12
+	in := []string{"Push", " issues ", "push", ""}
13
+	got, err := normalizeEvents(in)
14
+	if err != nil {
15
+		t.Fatalf("normalizeEvents err = %v", err)
16
+	}
17
+	want := []string{"push", "issues"}
18
+	if len(got) != len(want) {
19
+		t.Fatalf("normalizeEvents len = %d; want %d (%v)", len(got), len(want), got)
20
+	}
21
+	for i := range want {
22
+		if got[i] != want[i] {
23
+			t.Errorf("normalizeEvents[%d] = %q; want %q", i, got[i], want[i])
24
+		}
25
+	}
26
+}
27
+
28
+func TestNormalizeEventsRejectsTooLong(t *testing.T) {
29
+	long := strings.Repeat("a", 65)
30
+	if _, err := normalizeEvents([]string{long}); !errors.Is(err, ErrBadEvent) {
31
+		t.Fatalf("normalizeEvents(long) err = %v; want ErrBadEvent", err)
32
+	}
33
+}
34
+
35
+func TestValidateURLAcceptsHTTPS(t *testing.T) {
36
+	if err := validateURL("https://example.com/x"); err != nil {
37
+		t.Fatalf("validateURL(good) = %v", err)
38
+	}
39
+}
40
+
41
+func TestValidateURLRejectsBadShapes(t *testing.T) {
42
+	cases := []string{
43
+		"",
44
+		"file:///etc/passwd",
45
+		"http://",
46
+		"//example.com/x",
47
+	}
48
+	for _, raw := range cases {
49
+		if err := validateURL(raw); !errors.Is(err, ErrBadURL) {
50
+			t.Errorf("validateURL(%q) = %v; want ErrBadURL", raw, err)
51
+		}
52
+	}
53
+}