forked from
tangled.org/core
Monorepo for Tangled
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 "log/slog"
13 "net/http"
14 "time"
15
16 "github.com/avast/retry-go/v4"
17 "github.com/google/uuid"
18 "tangled.org/core/appview/db"
19 "tangled.org/core/appview/models"
20 "tangled.org/core/appview/notify"
21 "tangled.org/core/log"
22)
23
24type Notifier struct {
25 notify.BaseNotifier
26 db *db.DB
27 logger *slog.Logger
28 client *http.Client
29}
30
31func NewNotifier(database *db.DB) *Notifier {
32 return &Notifier{
33 db: database,
34 logger: log.New("webhook-notifier"),
35 client: &http.Client{
36 Timeout: 30 * time.Second,
37 },
38 }
39}
40
41var _ notify.Notifier = &Notifier{}
42
43func (w *Notifier) Push(ctx context.Context, repo *models.Repo, ref, oldSha, newSha, committerDid string) {
44 webhooks, err := db.GetActiveWebhooksForRepo(w.db, repo.RepoAt())
45 if err != nil {
46 w.logger.Error("failed to get webhooks for repo", "repo", repo.RepoAt(), "err", err)
47 return
48 }
49
50 var pushWebhooks []models.Webhook
51 for _, webhook := range webhooks {
52 if webhook.HasEvent(models.WebhookEventPush) {
53 pushWebhooks = append(pushWebhooks, webhook)
54 }
55 }
56
57 if len(pushWebhooks) == 0 {
58 return
59 }
60
61 payload, err := w.buildPushPayload(repo, ref, oldSha, newSha, committerDid)
62 if err != nil {
63 w.logger.Error("failed to build push payload", "repo", repo.RepoAt(), "err", err)
64 return
65 }
66
67 for _, webhook := range pushWebhooks {
68 go w.sendWebhook(ctx, webhook, string(models.WebhookEventPush), payload)
69 }
70}
71
72func (w *Notifier) buildPushPayload(repo *models.Repo, ref, oldSha, newSha, committerDid string) (*models.WebhookPayload, error) {
73 owner := repo.Did
74
75 pusher := committerDid
76 if committerDid == "" {
77 pusher = owner
78 }
79
80 repository := models.WebhookRepository{
81 Name: repo.Name,
82 FullName: fmt.Sprintf("%s/%s", repo.Did, repo.Name),
83 Description: repo.Description,
84 Fork: repo.Source != "",
85 HtmlUrl: fmt.Sprintf("https://%s/%s/%s", repo.Knot, repo.Did, repo.Name),
86 CloneUrl: fmt.Sprintf("https://%s/%s/%s", repo.Knot, repo.Did, repo.Name),
87 SshUrl: fmt.Sprintf("ssh://git@%s/%s/%s", repo.Knot, repo.Did, repo.Name),
88 CreatedAt: repo.Created.Format(time.RFC3339),
89 UpdatedAt: repo.Created.Format(time.RFC3339),
90 Owner: models.WebhookUser{
91 Did: owner,
92 },
93 }
94
95 if repo.Website != "" {
96 repository.Website = repo.Website
97 }
98 if repo.RepoStats != nil {
99 repository.StarsCount = repo.RepoStats.StarCount
100 repository.OpenIssues = repo.RepoStats.IssueCount.Open
101 }
102
103 payload := &models.WebhookPayload{
104 Ref: ref,
105 Before: oldSha,
106 After: newSha,
107 Repository: repository,
108 Pusher: models.WebhookUser{
109 Did: pusher,
110 },
111 }
112
113 return payload, nil
114}
115
116func (w *Notifier) sendWebhook(ctx context.Context, webhook models.Webhook, event string, payload *models.WebhookPayload) {
117 deliveryId := uuid.New().String()
118
119 payloadBytes, err := json.Marshal(payload)
120 if err != nil {
121 w.logger.Error("failed to marshal webhook payload", "webhook_id", webhook.Id, "err", err)
122 return
123 }
124
125 req, err := http.NewRequestWithContext(ctx, "POST", webhook.Url, bytes.NewReader(payloadBytes))
126 if err != nil {
127 w.logger.Error("failed to create webhook request", "webhook_id", webhook.Id, "err", err)
128 return
129 }
130
131 shortSha := payload.After[:7]
132
133 req.Header.Set("Content-Type", "application/json")
134 req.Header.Set("User-Agent", "Tangled-Hook/"+shortSha)
135 req.Header.Set("X-Tangled-Event", event)
136 req.Header.Set("X-Tangled-Hook-ID", fmt.Sprintf("%d", webhook.Id))
137 req.Header.Set("X-Tangled-Delivery", deliveryId)
138 req.Header.Set("X-Tangled-Repo", payload.Repository.FullName)
139
140 if webhook.Secret != "" {
141 signature := w.computeSignature(payloadBytes, webhook.Secret)
142 req.Header.Set("X-Tangled-Signature-256", "sha256="+signature)
143 }
144
145 delivery := &models.WebhookDelivery{
146 WebhookId: webhook.Id,
147 Event: event,
148 DeliveryId: deliveryId,
149 Url: webhook.Url,
150 RequestBody: string(payloadBytes),
151 }
152
153 retryOpts := []retry.Option{
154 retry.Attempts(3),
155 retry.Delay(1 * time.Second),
156 retry.MaxDelay(10 * time.Second),
157 retry.DelayType(retry.BackOffDelay),
158 retry.LastErrorOnly(true),
159 retry.OnRetry(func(n uint, err error) {
160 w.logger.Info("retrying webhook delivery",
161 "webhook_id", webhook.Id,
162 "attempt", n+1,
163 "err", err)
164 }),
165 retry.Context(ctx),
166 retry.RetryIf(func(err error) bool {
167 return err != nil
168 }),
169 }
170
171 var resp *http.Response
172 err = retry.Do(func() error {
173 var err error
174 resp, err = w.client.Do(req)
175 if err != nil {
176 return err
177 }
178 if resp.StatusCode >= 500 {
179 defer resp.Body.Close()
180 return fmt.Errorf("server error: %d", resp.StatusCode)
181 }
182 return nil
183 }, retryOpts...)
184
185 if err != nil {
186 w.logger.Error("webhook request failed after retries", "webhook_id", webhook.Id, "err", err)
187 delivery.Success = false
188 delivery.ResponseBody = err.Error()
189 } else {
190 defer resp.Body.Close()
191
192 delivery.ResponseCode = resp.StatusCode
193 delivery.Success = resp.StatusCode >= 200 && resp.StatusCode < 300
194
195 bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024))
196 if err != nil {
197 w.logger.Warn("failed to read webhook response body", "webhook_id", webhook.Id, "err", err)
198 } else {
199 delivery.ResponseBody = string(bodyBytes)
200 }
201
202 if !delivery.Success {
203 w.logger.Warn("webhook delivery failed",
204 "webhook_id", webhook.Id,
205 "status", resp.StatusCode,
206 "url", webhook.Url)
207 } else {
208 w.logger.Info("webhook delivered successfully",
209 "webhook_id", webhook.Id,
210 "url", webhook.Url,
211 "delivery_id", deliveryId)
212 }
213 }
214
215 if err := db.AddWebhookDelivery(w.db, delivery); err != nil {
216 w.logger.Error("failed to record webhook delivery", "webhook_id", webhook.Id, "err", err)
217 }
218}
219
220func (w *Notifier) computeSignature(payload []byte, secret string) string {
221 mac := hmac.New(sha256.New, []byte(secret))
222 mac.Write(payload)
223 return hex.EncodeToString(mac.Sum(nil))
224}