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}