|
| 1 | +require 'sinatra' |
| 2 | +require 'octokit' |
| 3 | +require 'json' |
| 4 | +require 'openssl' # Used to verify the webhook signature |
| 5 | +require 'jwt' # Used to authenticate a GitHub App |
| 6 | +require 'time' # Used to get ISO 8601 representation of a Time object |
| 7 | +require 'logger' |
| 8 | + |
| 9 | +set :port, 3000 |
| 10 | + |
| 11 | + |
| 12 | +# This is template code to create a GitHub App server. |
| 13 | +# You can read more about GitHub Apps here: # https://developer.github.com/apps/ |
| 14 | +# |
| 15 | +# On its own, this app does absolutely nothing, except that it can be installed. |
| 16 | +# It's up to you to add fun functionality! |
| 17 | +# You can check out one example in advanced_server.rb. |
| 18 | +# |
| 19 | +# This code is a Sinatra app, for two reasons: |
| 20 | +# 1. Because the app will require a landing page for installation. |
| 21 | +# 2. To easily handle webhook events. |
| 22 | +# |
| 23 | +# |
| 24 | +# Of course, not all apps need to receive and process events! |
| 25 | +# Feel free to rip out the event handling code if you don't need it. |
| 26 | +# |
| 27 | +# Have fun! |
| 28 | +# |
| 29 | + |
| 30 | +class GHAapp < Sinatra::Application |
| 31 | + |
| 32 | + # !!! DO NOT EVER USE HARD-CODED VALUES IN A REAL APP !!! |
| 33 | + # Instead, set and read app tokens or other secrets in your code |
| 34 | + # in a runtime source, like an environment variable like below |
| 35 | + |
| 36 | + # Expects that the private key has been set as an environment variable in |
| 37 | + # PEM format using the following command to replace newlines with the |
| 38 | + # literal `\n`: |
| 39 | + # export GITHUB_PRIVATE_KEY=`awk '{printf "%s\\n", $0}' private-key.pem` |
| 40 | + # |
| 41 | + # Converts the newlines |
| 42 | + PRIVATE_KEY = OpenSSL::PKey::RSA.new(ENV['GITHUB_PRIVATE_KEY'].gsub('\n', "\n")) |
| 43 | + |
| 44 | + # Your registered app must have a secret set. The secret is used to verify |
| 45 | + # that webhooks are sent by GitHub. |
| 46 | + WEBHOOK_SECRET = ENV['GITHUB_WEBHOOK_SECRET'] |
| 47 | + |
| 48 | + # The GitHub App's identifier (type integer) set when registering an app. |
| 49 | + APP_IDENTIFIER = ENV['GITHUB_APP_IDENTIFIER'] |
| 50 | + |
| 51 | + # Turn on Sinatra's verbose logging during development |
| 52 | + configure :development do |
| 53 | + set :logging, Logger::DEBUG |
| 54 | + end |
| 55 | + |
| 56 | + |
| 57 | + # Before each request to the `/event_handler` route |
| 58 | + before '/event_handler' do |
| 59 | + get_payload_request(request) |
| 60 | + verify_webhook_signature |
| 61 | + authenticate_app |
| 62 | + # Authenticate each installation of the app in order to run API operations |
| 63 | + authenticate_installation(@payload) |
| 64 | + end |
| 65 | + |
| 66 | + |
| 67 | + post '/event_handler' do |
| 68 | + |
| 69 | + case request.env['HTTP_X_GITHUB_EVENT'] |
| 70 | + when 'issues' |
| 71 | + if @payload['action'] === 'opened' |
| 72 | + handle_issue_opened_event(@payload) |
| 73 | + end |
| 74 | + end |
| 75 | + |
| 76 | + 'ok' # we have to return _something_ ;) |
| 77 | + |
| 78 | + end |
| 79 | + |
| 80 | + |
| 81 | + helpers do |
| 82 | + |
| 83 | + # When an issue is opened, add a label |
| 84 | + def handle_issue_opened_event(payload) |
| 85 | + repo = payload['repository']['full_name'] |
| 86 | + issue_number = payload['issue']['number'] |
| 87 | + @installation_client.add_labels_to_an_issue(repo, issue_number, ['needs-response']) |
| 88 | + end |
| 89 | + |
| 90 | + # Saves the raw payload and converts the payload to JSON format |
| 91 | + def get_payload_request(request) |
| 92 | + # request.body is an IO or StringIO object |
| 93 | + # Rewind in case someone already read it |
| 94 | + request.body.rewind |
| 95 | + # The raw text of the body is required for webhook signature verification |
| 96 | + @payload_raw = request.body.read |
| 97 | + begin |
| 98 | + @payload = JSON.parse @payload_raw |
| 99 | + rescue => e |
| 100 | + fail "Invalid JSON (#{e}): #{@payload_raw}" |
| 101 | + end |
| 102 | + end |
| 103 | + |
| 104 | + # Instantiate an Octokit client authenticated as a GitHub App. |
| 105 | + # GitHub App authentication equires that we construct a |
| 106 | + # JWT (https://jwt.io/introduction/) signed with the app's private key, |
| 107 | + # so GitHub can be sure that it came from the app an not altererd by |
| 108 | + # a malicious third party. |
| 109 | + def authenticate_app |
| 110 | + payload = { |
| 111 | + # The time that this JWT was issued, _i.e._ now. |
| 112 | + iat: Time.now.to_i, |
| 113 | + |
| 114 | + # JWT expiration time (10 minute maximum) |
| 115 | + exp: Time.now.to_i + (10 * 60), |
| 116 | + |
| 117 | + # Your GitHub App's identifier number |
| 118 | + iss: APP_IDENTIFIER |
| 119 | + } |
| 120 | + |
| 121 | + # Cryptographically sign the JWT |
| 122 | + jwt = JWT.encode(payload, PRIVATE_KEY, 'RS256') |
| 123 | + |
| 124 | + # Create the Octokit client, using the JWT as the auth token. |
| 125 | + @app_client ||= Octokit::Client.new(bearer_token: jwt) |
| 126 | + end |
| 127 | + |
| 128 | + # Instantiate an Octokit client authenticated as an installation of a |
| 129 | + # GitHub App to run API operations. |
| 130 | + def authenticate_installation(payload) |
| 131 | + installation_id = payload['installation']['id'] |
| 132 | + installation_token = @app_client.create_app_installation_access_token(installation_id)[:token] |
| 133 | + @installation_client = Octokit::Client.new(bearer_token: installation_token) |
| 134 | + end |
| 135 | + |
| 136 | + # Check X-Hub-Signature to confirm that this webhook was generated by |
| 137 | + # GitHub, and not a malicious third party. |
| 138 | + # |
| 139 | + # GitHub will the WEBHOOK_SECRET, registered |
| 140 | + # to the GitHub App, to create a hash signature sent in each webhook payload |
| 141 | + # in the `X-HUB-Signature` header. This code computes the expected hash |
| 142 | + # signature and compares it to the signature sent in the `X-HUB-Signature` |
| 143 | + # header. If they don't match, this request is an attack, and we should |
| 144 | + # reject it. GitHub uses the HMAC hexdigest to compute the signature. The |
| 145 | + # `X-HUB-Signature` looks something like this: "sha1=123456" |
| 146 | + # See https://developer.github.com/webhooks/securing/ for details |
| 147 | + def verify_webhook_signature |
| 148 | + their_signature_header = request.env['HTTP_X_HUB_SIGNATURE'] || 'sha1=' |
| 149 | + method, their_digest = their_signature_header.split('=') |
| 150 | + our_digest = OpenSSL::HMAC.hexdigest(method, WEBHOOK_SECRET, @payload_raw) |
| 151 | + halt 401 unless their_digest == our_digest |
| 152 | + |
| 153 | + # The X-GITHUB-EVENT header provides the name of the event. |
| 154 | + # The action value indicates the which action triggered the event. |
| 155 | + logger.debug "---- recevied event #{request.env['HTTP_X_GITHUB_EVENT']}" |
| 156 | + logger.debug "---- action #{@payload['action']}" unless @payload['action'].nil? |
| 157 | + end |
| 158 | + |
| 159 | + end |
| 160 | + |
| 161 | + |
| 162 | + # Finally some logic to let us run this server directly from the commandline, or with Rack |
| 163 | + # Don't worry too much about this code ;) But, for the curious: |
| 164 | + # $0 is the executed file |
| 165 | + # __FILE__ is the current file |
| 166 | + # If they are the same—that is, we are running this file directly, call the Sinatra run method |
| 167 | + run! if __FILE__ == $0 |
| 168 | +end |
0 commit comments