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