This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / notify / webhook / notifier.go
11 kB 371 lines
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/bluesky-social/indigo/atproto/syntax" 18 "github.com/google/uuid" 19 "tangled.org/core/appview/db" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/notify" 22 "tangled.org/core/hostutil" 23 "tangled.org/core/log" 24 "tangled.org/core/orm" 25) 26 27type Notifier struct { 28 notify.BaseNotifier 29 db *db.DB 30 baseUrl string 31 logger *slog.Logger 32 client *http.Client 33} 34 35func NewNotifier(database *db.DB, baseUrl string, dev bool) *Notifier { 36 return &Notifier{ 37 db: database, 38 baseUrl: baseUrl, 39 logger: log.New("webhook-notifier"), 40 // user-supplied webhook URLs are untrusted: block internal address 41 // ranges and don't follow redirects to guard against SSRF. 42 client: hostutil.SafeClient(dev, 30*time.Second), 43 } 44} 45 46var _ notify.Notifier = &Notifier{} 47 48func (w *Notifier) Push(ctx context.Context, repo *models.Repo, ref, oldSha, newSha, committerDid string) { 49 webhooks, err := w.activeWebhooksForEvent(repo.RepoDid, models.WebhookEventPush) 50 if err != nil { 51 w.logger.Error("failed to get webhooks for repo", "repo_did", repo.RepoDid, "err", err) 52 return 53 } 54 if len(webhooks) == 0 { 55 return 56 } 57 58 payload := w.buildPushPayload(repo, ref, oldSha, newSha, committerDid) 59 payloadBytes, err := json.Marshal(payload) 60 if err != nil { 61 w.logger.Error("failed to marshal push payload", "repo_did", repo.RepoDid, "err", err) 62 return 63 } 64 65 userAgent := "Tangled-Hook/" + newSha[:7] 66 for _, webhook := range webhooks { 67 go w.sendWebhook(ctx, webhook, string(models.WebhookEventPush), payload.Repository.FullName, userAgent, payloadBytes) 68 } 69} 70 71func (w *Notifier) RenameRepo(ctx context.Context, actor syntax.DID, oldRepo, newRepo *models.Repo) { 72 webhooks, err := w.activeWebhooksForEvent(newRepo.RepoDid, models.WebhookEventRepoRenamed) 73 if err != nil { 74 w.logger.Error("failed to get webhooks for repo", "repo_did", newRepo.RepoDid, "err", err) 75 return 76 } 77 if len(webhooks) == 0 { 78 return 79 } 80 81 payload := &models.WebhookRenamePayload{ 82 OldName: oldRepo.Name, 83 NewName: newRepo.Name, 84 Repository: buildWebhookRepository(newRepo), 85 Sender: models.WebhookUser{Did: actor.String()}, 86 } 87 payloadBytes, err := json.Marshal(payload) 88 if err != nil { 89 w.logger.Error("failed to marshal rename payload", "repo_did", newRepo.RepoDid, "err", err) 90 return 91 } 92 93 userAgent := "Tangled-Hook/rename" 94 for _, webhook := range webhooks { 95 go w.sendWebhook(ctx, webhook, string(models.WebhookEventRepoRenamed), payload.Repository.FullName, userAgent, payloadBytes) 96 } 97} 98 99func (w *Notifier) NewPull(ctx context.Context, pull *models.Pull) { 100 w.pullRequestEvent(ctx, models.WebhookEventPullRequestCreated, "created", pull.OwnerDid, pull) 101} 102 103func (w *Notifier) ResubmitPull(ctx context.Context, pull *models.Pull) { 104 w.pullRequestEvent(ctx, models.WebhookEventPullRequestResubmitted, "resubmitted", pull.OwnerDid, pull) 105} 106 107func (w *Notifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) { 108 event, action, ok := pullStateEvent(pull.State) 109 if !ok { 110 return 111 } 112 w.pullRequestEvent(ctx, event, action, actor.String(), pull) 113} 114 115// pullStateEvent maps a pull's state to the webhook event announcing the 116// transition into that state 117func pullStateEvent(state models.PullState) (models.WebhookEvent, string, bool) { 118 switch state { 119 case models.PullMerged: 120 return models.WebhookEventPullRequestMerged, "merged", true 121 case models.PullClosed: 122 return models.WebhookEventPullRequestClosed, "closed", true 123 case models.PullOpen: 124 return models.WebhookEventPullRequestReopened, "reopened", true 125 default: 126 return "", "", false 127 } 128} 129 130func (w *Notifier) pullRequestEvent(ctx context.Context, event models.WebhookEvent, action, sender string, pull *models.Pull) { 131 // pull request events originate from http handlers, whose context is 132 // canceled as soon as the handler returns; detach so in-flight 133 // deliveries are not cut short 134 ctx = context.WithoutCancel(ctx) 135 136 webhooks, err := w.activeWebhooksForEvent(string(pull.RepoDid), event) 137 if err != nil { 138 w.logger.Error("failed to get webhooks for repo", "repo_did", pull.RepoDid, "err", err) 139 return 140 } 141 if len(webhooks) == 0 { 142 return 143 } 144 145 repo, err := db.GetRepo(w.db, orm.FilterEq("repo_did", string(pull.RepoDid))) 146 if err != nil { 147 w.logger.Error("failed to get repo", "repo_did", pull.RepoDid, "err", err) 148 return 149 } 150 151 payload := buildPullRequestPayload(action, repo, pull, sender, w.baseUrl) 152 payloadBytes, err := json.Marshal(payload) 153 if err != nil { 154 w.logger.Error("failed to marshal pull request payload", "repo_did", pull.RepoDid, "err", err) 155 return 156 } 157 158 userAgent := "Tangled-Hook/pull_request" 159 for _, webhook := range webhooks { 160 go w.sendWebhook(ctx, webhook, string(event), payload.Repository.FullName, userAgent, payloadBytes) 161 } 162} 163 164func buildPullRequestPayload(action string, repo *models.Repo, pull *models.Pull, sender, baseUrl string) *models.WebhookPullRequestPayload { 165 htmlUrl := fmt.Sprintf("%s/%s/%s/pulls/%d", baseUrl, repo.Did, repo.Slug(), pull.PullId) 166 167 pullRequest := models.WebhookPullRequest{ 168 Number: pull.PullId, 169 Title: pull.Title, 170 Body: pull.Body, 171 State: pull.State.String(), 172 TargetBranch: pull.TargetBranch, 173 Owner: models.WebhookUser{Did: pull.OwnerDid}, 174 HtmlUrl: htmlUrl, 175 CreatedAt: pull.Created.Format(time.RFC3339), 176 } 177 if len(pull.Submissions) > 0 { 178 pullRequest.RoundNumber = pull.LastRoundNumber() 179 pullRequest.PatchUrl = fmt.Sprintf("%s/round/%d.patch", htmlUrl, pull.LastRoundNumber()) 180 } 181 if pull.PullSource != nil { 182 source := &models.WebhookPullRequestSource{ 183 Branch: pull.PullSource.Branch, 184 } 185 if len(pull.Submissions) > 0 { 186 source.Sha = pull.LatestSha() 187 } 188 if pull.IsForkBased() { 189 source.Repo = pull.PullSource.RepoDid.String() 190 } 191 pullRequest.Source = source 192 } 193 194 return &models.WebhookPullRequestPayload{ 195 Action: action, 196 PullRequest: pullRequest, 197 Repository: buildWebhookRepository(repo), 198 Sender: models.WebhookUser{Did: sender}, 199 } 200} 201 202// Redeliver re-sends a stored delivery via the live send-and-record path, 203// signing with the webhook's current secret. 204func (w *Notifier) Redeliver(ctx context.Context, webhook models.Webhook, prev models.WebhookDelivery) { 205 // recover the repo full name (X-Tangled-Repo header) from the stored payload 206 var meta struct { 207 Repository struct { 208 FullName string `json:"full_name"` 209 } `json:"repository"` 210 } 211 _ = json.Unmarshal([]byte(prev.RequestBody), &meta) 212 213 w.sendWebhook(ctx, webhook, prev.Event, meta.Repository.FullName, "Tangled-Hook/retry", []byte(prev.RequestBody)) 214} 215 216func (w *Notifier) activeWebhooksForEvent(repoDid string, event models.WebhookEvent) ([]models.Webhook, error) { 217 webhooks, err := db.GetActiveWebhooksForRepo(w.db, repoDid) 218 if err != nil { 219 return nil, err 220 } 221 var matching []models.Webhook 222 for _, webhook := range webhooks { 223 if webhook.HasEvent(event) { 224 matching = append(matching, webhook) 225 } 226 } 227 return matching, nil 228} 229 230func buildWebhookRepository(repo *models.Repo) models.WebhookRepository { 231 repository := models.WebhookRepository{ 232 Name: repo.Name, 233 FullName: fmt.Sprintf("%s/%s", repo.Did, repo.Rkey), 234 Description: repo.Description, 235 Fork: repo.Source != "", 236 HtmlUrl: fmt.Sprintf("https://%s/%s/%s", repo.Knot, repo.Did, repo.Rkey), 237 CloneUrl: fmt.Sprintf("https://%s/%s/%s", repo.Knot, repo.Did, repo.Rkey), 238 SshUrl: fmt.Sprintf("ssh://git@%s/%s/%s", repo.Knot, repo.Did, repo.Rkey), 239 CreatedAt: repo.Created.Format(time.RFC3339), 240 UpdatedAt: repo.Created.Format(time.RFC3339), 241 Owner: models.WebhookUser{ 242 Did: repo.Did, 243 }, 244 } 245 if repo.Website != "" { 246 repository.Website = repo.Website 247 } 248 if repo.RepoStats != nil { 249 repository.StarsCount = repo.RepoStats.StarCount 250 repository.OpenIssues = repo.RepoStats.IssueCount.Open 251 } 252 return repository 253} 254 255func (w *Notifier) buildPushPayload(repo *models.Repo, ref, oldSha, newSha, committerDid string) *models.WebhookPayload { 256 pusher := committerDid 257 if committerDid == "" { 258 pusher = repo.Did 259 } 260 return &models.WebhookPayload{ 261 Ref: ref, 262 Before: oldSha, 263 After: newSha, 264 Repository: buildWebhookRepository(repo), 265 Pusher: models.WebhookUser{ 266 Did: pusher, 267 }, 268 } 269} 270 271func (w *Notifier) sendWebhook(ctx context.Context, webhook models.Webhook, event, repoFullName, userAgent string, payloadBytes []byte) { 272 deliveryId := uuid.New().String() 273 274 req, err := http.NewRequestWithContext(ctx, "POST", webhook.Url, bytes.NewReader(payloadBytes)) 275 if err != nil { 276 w.logger.Error("failed to create webhook request", "webhook_id", webhook.Id, "err", err) 277 return 278 } 279 280 req.Header.Set("Content-Type", "application/json") 281 req.Header.Set("User-Agent", userAgent) 282 req.Header.Set("X-Tangled-Event", event) 283 req.Header.Set("X-Tangled-Hook-ID", fmt.Sprintf("%d", webhook.Id)) 284 req.Header.Set("X-Tangled-Delivery", deliveryId) 285 req.Header.Set("X-Tangled-Repo", repoFullName) 286 287 if webhook.Secret != "" { 288 signature := w.computeSignature(payloadBytes, webhook.Secret) 289 req.Header.Set("X-Tangled-Signature-256", "sha256="+signature) 290 } 291 292 delivery := &models.WebhookDelivery{ 293 WebhookId: webhook.Id, 294 Event: event, 295 DeliveryId: deliveryId, 296 Url: webhook.Url, 297 RequestBody: string(payloadBytes), 298 } 299 300 retryOpts := []retry.Option{ 301 retry.Attempts(3), 302 retry.Delay(1 * time.Second), 303 retry.MaxDelay(10 * time.Second), 304 retry.DelayType(retry.BackOffDelay), 305 retry.LastErrorOnly(true), 306 retry.OnRetry(func(n uint, err error) { 307 w.logger.Info("retrying webhook delivery", 308 "webhook_id", webhook.Id, 309 "attempt", n+1, 310 "err", err) 311 }), 312 retry.Context(ctx), 313 retry.RetryIf(func(err error) bool { 314 return err != nil 315 }), 316 } 317 318 var resp *http.Response 319 err = retry.Do(func() error { 320 var err error 321 resp, err = w.client.Do(req) 322 if err != nil { 323 return err 324 } 325 if resp.StatusCode >= 500 { 326 defer resp.Body.Close() 327 return fmt.Errorf("server error: %d", resp.StatusCode) 328 } 329 return nil 330 }, retryOpts...) 331 332 if err != nil { 333 w.logger.Error("webhook request failed after retries", "webhook_id", webhook.Id, "err", err) 334 delivery.Success = false 335 delivery.ResponseBody = err.Error() 336 } else { 337 defer resp.Body.Close() 338 339 delivery.ResponseCode = resp.StatusCode 340 delivery.Success = resp.StatusCode >= 200 && resp.StatusCode < 300 341 342 bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024)) 343 if err != nil { 344 w.logger.Warn("failed to read webhook response body", "webhook_id", webhook.Id, "err", err) 345 } else { 346 delivery.ResponseBody = string(bodyBytes) 347 } 348 349 if !delivery.Success { 350 w.logger.Warn("webhook delivery failed", 351 "webhook_id", webhook.Id, 352 "status", resp.StatusCode, 353 "url", webhook.Url) 354 } else { 355 w.logger.Info("webhook delivered successfully", 356 "webhook_id", webhook.Id, 357 "url", webhook.Url, 358 "delivery_id", deliveryId) 359 } 360 } 361 362 if err := db.AddWebhookDelivery(w.db, delivery); err != nil { 363 w.logger.Error("failed to record webhook delivery", "webhook_id", webhook.Id, "err", err) 364 } 365} 366 367func (w *Notifier) computeSignature(payload []byte, secret string) string { 368 mac := hmac.New(sha256.New, []byte(secret)) 369 mac.Write(payload) 370 return hex.EncodeToString(mac.Sum(nil)) 371}