soft-serve

git clone git://git.lin.moe/fork/soft-serve.git

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