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()) //nolint: errcheck
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)) //nolint: errcheck
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() //nolint: errcheck
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}