| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | # Receives GitHub push webhooks and triggers an immediate mirror sync for any |
| 4 | # mirror whose upstream matches the pushed repo. Optional: mirrors also sync on |
| 5 | # a schedule, so this is just a freshness optimization. |
| 6 | # |
| 7 | # Authenticated by the HMAC signature GitHub sends (X-Hub-Signature-256), keyed |
| 8 | # by GITHUB_WEBHOOK_SECRET. Without a configured secret the endpoint refuses |
| 9 | # requests rather than trusting unsigned input. |
| 10 | # |
| 11 | # Separate from GithubWebhooksController (the GitHub App's PR-review endpoint at |
| 12 | # /github/webhooks): different path, different secret, different trust domain. |
| 13 | class MirrorWebhooksController < ActionController::API |
| 14 | # POST /webhooks/github |
| 15 | def create |
| 16 | return head(:service_unavailable) if webhook_secret.blank? |
| 17 | |
| 18 | payload = request.body.read |
| 19 | return head(:unauthorized) unless valid_signature?(payload) |
| 20 | |
| 21 | event = request.headers["X-GitHub-Event"] |
| 22 | # Respond OK to ping so the webhook can be verified in GitHub's UI. |
| 23 | return head(:ok) if event == "ping" |
| 24 | return head(:ok) unless event == "push" |
| 25 | |
| 26 | data = JSON.parse(payload) rescue {} |
| 27 | urls = candidate_urls(data) |
| 28 | return head(:ok) if urls.empty? |
| 29 | |
| 30 | Repository.mirrors.where(upstream_url: urls).find_each do |repo| |
| 31 | MirrorSyncJob.perform_later(repo.id) |
| 32 | end |
| 33 | |
| 34 | head :ok |
| 35 | end |
| 36 | |
| 37 | private |
| 38 | |
| 39 | # The clone URLs GitHub might have stored as our upstream for this repo. |
| 40 | def candidate_urls(data) |
| 41 | repo = data["repository"] || {} |
| 42 | [ repo["clone_url"], repo["git_url"], repo["html_url"], repo["ssh_url"] ].compact.uniq |
| 43 | end |
| 44 | |
| 45 | def valid_signature?(payload) |
| 46 | sig = request.headers["X-Hub-Signature-256"].to_s |
| 47 | return false if sig.blank? |
| 48 | |
| 49 | expected = "sha256=" + OpenSSL::HMAC.hexdigest("sha256", webhook_secret, payload) |
| 50 | ActiveSupport::SecurityUtils.secure_compare(sig, expected) |
| 51 | end |
| 52 | |
| 53 | def webhook_secret |
| 54 | ENV["GITHUB_WEBHOOK_SECRET"].presence |
| 55 | end |
| 56 | end |