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