1package webhook23import (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"1415 "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)2627// Hook is a repository webhook.28type Hook struct {29 models.Webhook30 ContentType ContentType31 Events []Event32}3334// Delivery is a webhook delivery.35type Delivery struct {36 models.WebhookDelivery37 Event Event38}3940// secureHTTPClient is an HTTP client with SSRF protection.41var secureHTTPClient = ssrf.NewSecureClient()4243// 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, err49 }5051 req.Header = headers52 res, err := secureHTTPClient.Do(req)53 if err != nil {54 return nil, err55 }5657 return res, nil58}5960// SendWebhook sends a webhook event.61func SendWebhook(ctx context.Context, w models.Webhook, event Event, payload interface{}) error {62 var buf bytes.Buffer63 dbx := db.FromContext(ctx)64 datastore := store.FromContext(ctx)6566 contentType := ContentType(w.ContentType) //nolint:gosec67 switch contentType {68 case ContentTypeJSON:69 if err := json.NewEncoder(&buf).Encode(payload); err != nil {70 return err71 }72 case ContentTypeForm:73 v, err := query.Values(payload)74 if err != nil {75 return err76 }77 buf.WriteString(v.Encode()) //nolint: errcheck78 default:79 return ErrInvalidContentType80 }8182 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())8687 id, err := uuid.NewUUID()88 if err != nil {89 return err90 }9192 headers.Add("X-SoftServe-Delivery", id.String())9394 reqBody := buf.String()95 if w.Secret != "" {96 sig := hmac.New(sha256.New, []byte(w.Secret))97 sig.Write([]byte(reqBody)) //nolint: errcheck98 headers.Add("X-SoftServe-Signature", "sha256="+hex.EncodeToString(sig.Sum(nil)))99 }100101 res, reqErr := do(ctx, w.URL, http.MethodPost, headers, &buf)102 var reqHeaders string103 for k, v := range headers {104 reqHeaders += k + ": " + v[0] + "\n"105 }106107 resStatus := 0108 resHeaders := ""109 resBody := ""110111 if res != nil {112 resStatus = res.StatusCode113 for k, v := range res.Header {114 resHeaders += k + ": " + v[0] + "\n"115 }116117 if res.Body != nil {118 defer res.Body.Close() //nolint: errcheck119 b, err := io.ReadAll(res.Body)120 if err != nil {121 return err122 }123124 resBody = string(b)125 }126 }127128 return db.WrapError(datastore.CreateWebhookDelivery(ctx, dbx, id, w.ID, int(event), w.URL, http.MethodPost, reqErr, reqHeaders, reqBody, resStatus, resHeaders, resBody))129}130131// 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 }139140 for _, w := range webhooks {141 if err := SendWebhook(ctx, w, payload.Event(), payload); err != nil {142 return err143 }144 }145146 return nil147}148149func repoURL(publicURL string, repo string) string {150 return fmt.Sprintf("%s/%s.git", publicURL, utils.SanitizeRepo(repo))151}152153func getDefaultBranch(repo proto.Repository) (string, error) {154 branch, err := proto.RepositoryDefaultBranch(repo)155 // XXX: we check for ErrReferenceNotExist here because we don't want to156 // 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 is158 // the first push to it.159 if err != nil && !errors.Is(err, git.ErrReferenceNotExist) {160 return "", err161 }162163 return branch, nil164}