webhook.go

  1package webhook
  2
  3import (
  4	"bytes"
  5	"context"
  6	"crypto/hmac"
  7	"crypto/sha256"
  8	"encoding/hex"
  9	"encoding/json"
 10	"errors"
 11	"fmt"
 12	"io"
 13	"net/http"
 14
 15	"github.com/charmbracelet/soft-serve/git"
 16	"github.com/charmbracelet/soft-serve/pkg/db"
 17	"github.com/charmbracelet/soft-serve/pkg/db/models"
 18	"github.com/charmbracelet/soft-serve/pkg/proto"
 19	"github.com/charmbracelet/soft-serve/pkg/store"
 20	"github.com/charmbracelet/soft-serve/pkg/utils"
 21	"github.com/charmbracelet/soft-serve/pkg/version"
 22	"github.com/google/go-querystring/query"
 23	"github.com/google/uuid"
 24)
 25
 26// Hook is a repository webhook.
 27type Hook struct {
 28	models.Webhook
 29	ContentType ContentType
 30	Events      []Event
 31}
 32
 33// Delivery is a webhook delivery.
 34type Delivery struct {
 35	models.WebhookDelivery
 36	Event Event
 37}
 38
 39// do sends a webhook.
 40// Caller must close the returned body.
 41func do(ctx context.Context, url string, method string, headers http.Header, body io.Reader) (*http.Response, error) {
 42	req, err := http.NewRequestWithContext(ctx, method, url, body)
 43	if err != nil {
 44		return nil, err
 45	}
 46
 47	req.Header = headers
 48	res, err := http.DefaultClient.Do(req)
 49	if err != nil {
 50		return nil, err
 51	}
 52
 53	return res, nil
 54}
 55
 56// SendWebhook sends a webhook event.
 57func SendWebhook(ctx context.Context, w models.Webhook, event Event, payload interface{}) error {
 58	var buf bytes.Buffer
 59	dbx := db.FromContext(ctx)
 60	datastore := store.FromContext(ctx)
 61
 62	contentType := ContentType(w.ContentType) //nolint:gosec
 63	switch contentType {
 64	case ContentTypeJSON:
 65		if err := json.NewEncoder(&buf).Encode(payload); err != nil {
 66			return err
 67		}
 68	case ContentTypeForm:
 69		v, err := query.Values(payload)
 70		if err != nil {
 71			return err
 72		}
 73		buf.WriteString(v.Encode()) 
 74	default:
 75		return ErrInvalidContentType
 76	}
 77
 78	headers := http.Header{}
 79	headers.Add("Content-Type", contentType.String())
 80	headers.Add("User-Agent", "SoftServe/"+version.Version)
 81	headers.Add("X-SoftServe-Event", event.String())
 82
 83	id, err := uuid.NewUUID()
 84	if err != nil {
 85		return err
 86	}
 87
 88	headers.Add("X-SoftServe-Delivery", id.String())
 89
 90	reqBody := buf.String()
 91	if w.Secret != "" {
 92		sig := hmac.New(sha256.New, []byte(w.Secret))
 93		sig.Write([]byte(reqBody)) 
 94		headers.Add("X-SoftServe-Signature", "sha256="+hex.EncodeToString(sig.Sum(nil)))
 95	}
 96
 97	res, reqErr := do(ctx, w.URL, http.MethodPost, headers, &buf)
 98	var reqHeaders string
 99	for k, v := range headers {
100		reqHeaders += k + ": " + v[0] + "\n"
101	}
102
103	resStatus := 0
104	resHeaders := ""
105	resBody := ""
106
107	if res != nil {
108		resStatus = res.StatusCode
109		for k, v := range res.Header {
110			resHeaders += k + ": " + v[0] + "\n"
111		}
112
113		if res.Body != nil {
114			defer res.Body.Close() 
115			b, err := io.ReadAll(res.Body)
116			if err != nil {
117				return err
118			}
119
120			resBody = string(b)
121		}
122	}
123
124	return db.WrapError(datastore.CreateWebhookDelivery(ctx, dbx, id, w.ID, int(event), w.URL, http.MethodPost, reqErr, reqHeaders, reqBody, resStatus, resHeaders, resBody))
125}
126
127// SendEvent sends a webhook event.
128func SendEvent(ctx context.Context, payload EventPayload) error {
129	dbx := db.FromContext(ctx)
130	datastore := store.FromContext(ctx)
131	webhooks, err := datastore.GetWebhooksByRepoIDWhereEvent(ctx, dbx, payload.RepositoryID(), []int{int(payload.Event())})
132	if err != nil {
133		return db.WrapError(err)
134	}
135
136	for _, w := range webhooks {
137		if err := SendWebhook(ctx, w, payload.Event(), payload); err != nil {
138			return err
139		}
140	}
141
142	return nil
143}
144
145func repoURL(publicURL string, repo string) string {
146	return fmt.Sprintf("%s/%s.git", publicURL, utils.SanitizeRepo(repo))
147}
148
149func getDefaultBranch(repo proto.Repository) (string, error) {
150	branch, err := proto.RepositoryDefaultBranch(repo)
151	// XXX: we check for ErrReferenceNotExist here because we don't want to
152	// return an error if the repo is an empty repo.
153	// This means that the repo doesn't have a default branch yet and this is
154	// the first push to it.
155	if err != nil && !errors.Is(err, git.ErrReferenceNotExist) {
156		return "", err
157	}
158
159	return branch, nil
160}