webhooks.go

  1package backend
  2
  3import (
  4	"context"
  5	"encoding/json"
  6
  7	"github.com/charmbracelet/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.
204func (b *Backend) ListWebhookDeliveries(ctx context.Context, id int64) ([]webhook.Delivery, error) {
205	dbx := db.FromContext(ctx)
206	datastore := store.FromContext(ctx)
207
208	var deliveries []models.WebhookDelivery
209	if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
210		var err error
211		deliveries, err = datastore.ListWebhookDeliveriesByWebhookID(ctx, tx, id)
212		if err != nil {
213			return db.WrapError(err)
214		}
215
216		return nil
217	}); err != nil {
218		return nil, db.WrapError(err)
219	}
220
221	ds := make([]webhook.Delivery, len(deliveries))
222	for i, d := range deliveries {
223		ds[i] = webhook.Delivery{
224			WebhookDelivery: d,
225			Event:           webhook.Event(d.Event),
226		}
227	}
228
229	return ds, nil
230}
231
232// RedeliverWebhookDelivery redelivers a webhook delivery.
233func (b *Backend) RedeliverWebhookDelivery(ctx context.Context, repo proto.Repository, id int64, delID uuid.UUID) error {
234	dbx := db.FromContext(ctx)
235	datastore := store.FromContext(ctx)
236
237	var delivery models.WebhookDelivery
238	var wh models.Webhook
239	if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
240		var err error
241		wh, err = datastore.GetWebhookByID(ctx, tx, repo.ID(), id)
242		if err != nil {
243			log.Errorf("error getting webhook: %v", err)
244			return db.WrapError(err)
245		}
246
247		delivery, err = datastore.GetWebhookDeliveryByID(ctx, tx, id, delID)
248		if err != nil {
249			return db.WrapError(err)
250		}
251
252		return nil
253	}); err != nil {
254		return db.WrapError(err)
255	}
256
257	log.Infof("redelivering webhook delivery %s for webhook %d\n\n%s\n\n", delID, id, delivery.RequestBody)
258
259	var payload json.RawMessage
260	if err := json.Unmarshal([]byte(delivery.RequestBody), &payload); err != nil {
261		log.Errorf("error unmarshaling webhook payload: %v", err)
262		return err
263	}
264
265	return webhook.SendWebhook(ctx, wh, webhook.Event(delivery.Event), payload)
266}
267
268// WebhookDelivery returns a webhook delivery.
269func (b *Backend) WebhookDelivery(ctx context.Context, webhookID int64, id uuid.UUID) (webhook.Delivery, error) {
270	dbx := db.FromContext(ctx)
271	datastore := store.FromContext(ctx)
272
273	var delivery webhook.Delivery
274	if err := dbx.TransactionContext(ctx, func(tx *db.Tx) error {
275		d, err := datastore.GetWebhookDeliveryByID(ctx, tx, webhookID, id)
276		if err != nil {
277			return db.WrapError(err)
278		}
279
280		delivery = webhook.Delivery{
281			WebhookDelivery: d,
282			Event:           webhook.Event(d.Event),
283		}
284
285		return nil
286	}); err != nil {
287		return webhook.Delivery{}, db.WrapError(err)
288	}
289
290	return delivery, nil
291}