Monorepo for Tangled
at master 224 lines 5.9 kB view raw
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}