webhooks.go
8729 bytes
1package backend
2
3import (
4 "context"
5 "encoding/json"
6
7 "charm.land/log/v2"
8 "github.com/charmbracelet/soft-serve/pkg/db"
9 "github.com/charmbracelet/soft-serve/pkg/db/models"
10 "github.com/charmbracelet/soft-serve/pkg/proto"
11 "github.com/charmbracelet/soft-serve/pkg/store"
12 "github.com/charmbracelet/soft-serve/pkg/utils"
13 "github.com/charmbracelet/soft-serve/pkg/webhook"
14 "github.com/google/uuid"
15)
16
17// CreateWebhook creates a webhook for a repository.
18func (b *Backend) CreateWebhook(ctx context.Context, repo proto.Repository, url string, contentType webhook.ContentType, secret string, events []webhook.Event, active bool) error {
19 dbx := db.FromContext(ctx)
20 datastore := store.FromContext(ctx)
21 url = utils.Sanitize(url)
22
23 // Validate webhook URL to prevent SSRF attacks
24 if err := webhook.ValidateWebhookURL(url); err != nil {
25 return err //nolint:wrapcheck
26 }
27
28 return dbx.TransactionContext(ctx, func(tx *db.Tx) error {
29 lastID, err := datastore.CreateWebhook(ctx, tx, repo.ID(), url, secret, int(contentType), active)
30 if err != nil {
31 return db.WrapError(err)
32 }
33
34 evs := make([]int, len(events))
35 for i, e := range events {
36 evs[i] = int(e)
37 }
38 if err := datastore.CreateWebhookEvents(ctx, tx, lastID, evs); err != nil {
39 return db.WrapError(err)
40 }
41
42 return nil
43 })
44}
45
46// Webhook returns a webhook for a repository.
47func (b *Backend) Webhook(ctx context.Context, repo proto.Repository, id int64) (webhook.Hook, error) {
48 dbx := db.FromContext(ctx)
49 datastore := store.FromContext(ctx)
50
51 var wh webhook.Hook
52 if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
53 h, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id)
54 if err != nil {
55 return db.WrapError(err)
56 }
57 events, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, id)
58 if err != nil {
59 return db.WrapError(err)
60 }
61
62 wh = webhook.Hook{
63 Webhook: h,
64 ContentType: webhook.ContentType(h.ContentType), //nolint:gosec
65 Events: make([]webhook.Event, len(events)),
66 }
67 for i, e := range events {
68 wh.Events[i] = webhook.Event(e.Event)
69 }
70
71 return nil
72 }); err != nil {
73 return webhook.Hook{}, db.WrapError(err)
74 }
75
76 return wh, nil
77}
78
79// ListWebhooks lists webhooks for a repository.
80func (b *Backend) ListWebhooks(ctx context.Context, repo proto.Repository) ([]webhook.Hook, error) {
81 dbx := db.FromContext(ctx)
82 datastore := store.FromContext(ctx)
83
84 var webhooks []models.Webhook
85 webhookEvents := map[int64][]models.WebhookEvent{}
86 if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
87 var err error
88 webhooks, err = datastore.GetWebhooksByRepoID(ctx, tx, repo.ID())
89 if err != nil {
90 return err
91 }
92
93 for _, h := range webhooks {
94 events, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, h.ID)
95 if err != nil {
96 return err
97 }
98 webhookEvents[h.ID] = events
99 }
100
101 return nil
102 }); err != nil {
103 return nil, db.WrapError(err)
104 }
105
106 hooks := make([]webhook.Hook, len(webhooks))
107 for i, h := range webhooks {
108 events := make([]webhook.Event, len(webhookEvents[h.ID]))
109 for i, e := range webhookEvents[h.ID] {
110 events[i] = webhook.Event(e.Event)
111 }
112
113 hooks[i] = webhook.Hook{
114 Webhook: h,
115 ContentType: webhook.ContentType(h.ContentType), //nolint:gosec
116 Events: events,
117 }
118 }
119
120 return hooks, nil
121}
122
123// UpdateWebhook updates a webhook.
124func (b *Backend) UpdateWebhook(ctx context.Context, repo proto.Repository, id int64, url string, contentType webhook.ContentType, secret string, updatedEvents []webhook.Event, active bool) error {
125 dbx := db.FromContext(ctx)
126 datastore := store.FromContext(ctx)
127
128 // Validate webhook URL to prevent SSRF attacks
129 if err := webhook.ValidateWebhookURL(url); err != nil {
130 return err
131 }
132
133 return dbx.TransactionContext(ctx, func(tx *db.Tx) error {
134 if err := datastore.UpdateWebhookByID(ctx, tx, repo.ID(), id, url, secret, int(contentType), active); err != nil {
135 return db.WrapError(err)
136 }
137
138 currentEvents, err := datastore.GetWebhookEventsByWebhookID(ctx, tx, id)
139 if err != nil {
140 return db.WrapError(err)
141 }
142
143 // Delete events that are no longer in the list.
144 toBeDeleted := make([]int64, 0)
145 for _, e := range currentEvents {
146 found := false
147 for _, ne := range updatedEvents {
148 if int(ne) == e.Event {
149 found = true
150 break
151 }
152 }
153 if !found {
154 toBeDeleted = append(toBeDeleted, e.ID)
155 }
156 }
157
158 if err := datastore.DeleteWebhookEventsByID(ctx, tx, toBeDeleted); err != nil {
159 return db.WrapError(err)
160 }
161
162 // Prune events that are already in the list.
163 newEvents := make([]int, 0)
164 for _, e := range updatedEvents {
165 found := false
166 for _, ne := range currentEvents {
167 if int(e) == ne.Event {
168 found = true
169 break
170 }
171 }
172 if !found {
173 newEvents = append(newEvents, int(e))
174 }
175 }
176
177 if err := datastore.CreateWebhookEvents(ctx, tx, id, newEvents); err != nil {
178 return db.WrapError(err)
179 }
180
181 return nil
182 })
183}
184
185// DeleteWebhook deletes a webhook for a repository.
186func (b *Backend) DeleteWebhook(ctx context.Context, repo proto.Repository, id int64) error {
187 dbx := db.FromContext(ctx)
188 datastore := store.FromContext(ctx)
189
190 return dbx.TransactionContext(ctx, func(tx *db.Tx) error {
191 _, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id)
192 if err != nil {
193 return db.WrapError(err)
194 }
195 if err := datastore.DeleteWebhookForRepoByID(ctx, tx, repo.ID(), id); err != nil {
196 return db.WrapError(err)
197 }
198
199 return nil
200 })
201}
202
203// ListWebhookDeliveries lists webhook deliveries for a webhook belonging to
204// the given repository.
205//
206// The webhook is resolved scoped to the repository, so a caller authorized
207// only for repo A cannot read deliveries of a webhook owned by repo B.
208func (b *Backend) ListWebhookDeliveries(ctx context.Context, repo proto.Repository, id int64) ([]webhook.Delivery, error) {
209 dbx := db.FromContext(ctx)
210 datastore := store.FromContext(ctx)
211
212 var deliveries []models.WebhookDelivery
213 if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
214 if _, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), id); err != nil {
215 return db.WrapError(err)
216 }
217
218 var err error
219 deliveries, err = datastore.ListWebhookDeliveriesByWebhookID(ctx, tx, id)
220 if err != nil {
221 return db.WrapError(err)
222 }
223
224 return nil
225 }); err != nil {
226 return nil, db.WrapError(err)
227 }
228
229 ds := make([]webhook.Delivery, len(deliveries))
230 for i, d := range deliveries {
231 ds[i] = webhook.Delivery{
232 WebhookDelivery: d,
233 Event: webhook.Event(d.Event),
234 }
235 }
236
237 return ds, nil
238}
239
240// RedeliverWebhookDelivery redelivers a webhook delivery.
241func (b *Backend) RedeliverWebhookDelivery(ctx context.Context, repo proto.Repository, id int64, delID uuid.UUID) error {
242 dbx := db.FromContext(ctx)
243 datastore := store.FromContext(ctx)
244
245 var delivery models.WebhookDelivery
246 var wh models.Webhook
247 if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
248 var err error
249 wh, err = datastore.GetWebhookByID(ctx, tx, repo.ID(), id)
250 if err != nil {
251 log.Errorf("error getting webhook: %v", err)
252 return db.WrapError(err)
253 }
254
255 delivery, err = datastore.GetWebhookDeliveryByID(ctx, tx, id, delID)
256 if err != nil {
257 return db.WrapError(err)
258 }
259
260 return nil
261 }); err != nil {
262 return db.WrapError(err)
263 }
264
265 log.Infof("redelivering webhook delivery %s for webhook %d\n\n%s\n\n", delID, id, delivery.RequestBody)
266
267 var payload json.RawMessage
268 if err := json.Unmarshal([]byte(delivery.RequestBody), &payload); err != nil {
269 log.Errorf("error unmarshaling webhook payload: %v", err)
270 return err
271 }
272
273 return webhook.SendWebhook(ctx, wh, webhook.Event(delivery.Event), payload)
274}
275
276// WebhookDelivery returns a webhook delivery for a webhook belonging to the
277// given repository.
278//
279// The webhook is resolved scoped to the repository, so a caller authorized
280// only for repo A cannot read a delivery of a webhook owned by repo B.
281// Deliveries expose the full request URL, headers, and body, so this scoping
282// is what keeps them from leaking across repositories.
283func (b *Backend) WebhookDelivery(ctx context.Context, repo proto.Repository, webhookID int64, id uuid.UUID) (webhook.Delivery, error) {
284 dbx := db.FromContext(ctx)
285 datastore := store.FromContext(ctx)
286
287 var delivery webhook.Delivery
288 if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
289 if _, err := datastore.GetWebhookByID(ctx, tx, repo.ID(), webhookID); err != nil {
290 return db.WrapError(err)
291 }
292
293 d, err := datastore.GetWebhookDeliveryByID(ctx, tx, webhookID, id)
294 if err != nil {
295 return db.WrapError(err)
296 }
297
298 delivery = webhook.Delivery{
299 WebhookDelivery: d,
300 Event: webhook.Event(d.Event),
301 }
302
303 return nil
304 }); err != nil {
305 return webhook.Delivery{}, db.WrapError(err)
306 }
307
308 return delivery, nil
309}