|
| 1 | +require 'sinatra' |
| 2 | +require 'logger' |
| 3 | +require 'json' |
| 4 | +require 'openssl' |
| 5 | +require 'octokit' |
| 6 | +require 'jwt' |
| 7 | +require 'time' # This is necessary to get the ISO 8601 representation of a Time object |
| 8 | + |
| 9 | +set :port, 3000 |
| 10 | + |
| 11 | +# |
| 12 | +# |
| 13 | +# This is a boilerplate server for your own GitHub App. You can read more about GitHub Apps here: |
| 14 | +# https://developer.github.com/apps/ |
| 15 | +# |
| 16 | +# On its own, this app does absolutely nothing, except that it can be installed. |
| 17 | +# It's up to you to add fun functionality! |
| 18 | +# You can check out one example in advanced_server.rb. |
| 19 | +# |
| 20 | +# This code is a Sinatra app, for two reasons. |
| 21 | +# First, because the app will require a landing page for installation. |
| 22 | +# Second, in anticipation that you will want to receive events over a webhook from GitHub, and respond to those |
| 23 | +# in some way. Of course, not all apps need to receive and process events! Feel free to rip out the event handling |
| 24 | +# code if you don't need it. |
| 25 | +# |
| 26 | +# Have fun! Please reach out to us if you have any questions, or just to show off what you've built! |
| 27 | +# |
| 28 | + |
| 29 | +class GHAapp < Sinatra::Application |
| 30 | + |
| 31 | +# Never, ever, hardcode app tokens or other secrets in your code! |
| 32 | +# Always extract from a runtime source, like an environment variable. |
| 33 | + |
| 34 | + |
| 35 | +# Notice that the private key must be in PEM format, but the newlines should be stripped and replaced with |
| 36 | +# the literal `\n`. This can be done in the terminal as such: |
| 37 | +# export GITHUB_PRIVATE_KEY=`awk '{printf "%s\\n", $0}' private-key.pem` |
| 38 | + PRIVATE_KEY = OpenSSL::PKey::RSA.new(ENV['GITHUB_PRIVATE_KEY'].gsub('\n', "\n")) # convert newlines |
| 39 | + |
| 40 | +# You set the webhook secret when you create your app. This verifies that the webhook is really coming from GH. |
| 41 | + WEBHOOK_SECRET = ENV['GITHUB_WEBHOOK_SECRET'] |
| 42 | + |
| 43 | +# Get the app identifier—an integer—from your app page after you create your app. This isn't actually a secret, |
| 44 | +# but it is something easier to configure at runtime. |
| 45 | + APP_IDENTIFIER = ENV['GITHUB_APP_IDENTIFIER'] |
| 46 | + |
| 47 | + |
| 48 | +########## Configure Sinatra |
| 49 | +# |
| 50 | +# Let's turn on verbose logging during development |
| 51 | +# |
| 52 | + configure :development do |
| 53 | + set :logging, Logger::DEBUG |
| 54 | + end |
| 55 | + |
| 56 | + |
| 57 | +########## Before each request to our app |
| 58 | +# |
| 59 | +# Before each request to our app, we want to instantiate an Octokit client. Doing so requires that we construct a JWT. |
| 60 | +# https://jwt.io/introduction/ |
| 61 | +# We have to also sign that JWT with our private key, so GitHub can be sure that |
| 62 | +# a) it came from us |
| 63 | +# b) it hasn't been altered by a malicious third party |
| 64 | +# |
| 65 | + before do |
| 66 | + payload = { |
| 67 | + # The time that this JWT was issued, _i.e._ now. |
| 68 | + iat: Time.now.to_i, |
| 69 | + |
| 70 | + # How long is the JWT good for (in seconds)? |
| 71 | + # Let's say it can be used for 10 minutes before it needs to be refreshed. |
| 72 | + # TODO we don't actually cache this token, we regenerate a new one every time! |
| 73 | + exp: Time.now.to_i + (10 * 60), |
| 74 | + |
| 75 | + # Your GitHub App's identifier number, so GitHub knows who issued the JWT, and know what permissions |
| 76 | + # this token has. |
| 77 | + iss: APP_IDENTIFIER |
| 78 | + } |
| 79 | + |
| 80 | + # Cryptographically sign the JWT |
| 81 | + jwt = JWT.encode(payload, PRIVATE_KEY, 'RS256') |
| 82 | + |
| 83 | + # Create the Octokit client, using the JWT as the auth token. |
| 84 | + # Notice that this client will _not_ have sufficient permissions to do many interesting things! |
| 85 | + # We might, for particular endpoints, need to generate an installation token (using the JWT), and instantiate |
| 86 | + # a new client object. But we'll cross that bridge when/if we get there! |
| 87 | + @client ||= Octokit::Client.new(bearer_token: jwt) |
| 88 | + end |
| 89 | + |
| 90 | + |
| 91 | + |
| 92 | + |
| 93 | +########## Events |
| 94 | +# |
| 95 | +# This is the webhook endpoint that GH will call with events, and hence where we will do our event handling |
| 96 | +# |
| 97 | + |
| 98 | + post '/' do |
| 99 | + request.body.rewind |
| 100 | + payload_raw = request.body.read # We need the raw text of the body to check the webhook signature |
| 101 | + begin |
| 102 | + payload = JSON.parse payload_raw |
| 103 | + rescue |
| 104 | + payload = {} |
| 105 | + end |
| 106 | + |
| 107 | + # Check X-Hub-Signature to confirm that this webhook was generated by GitHub, and not a malicious third party. |
| 108 | + # The way this works is: We have registered with GitHub a secret, and we have stored it locally in WEBHOOK_SECRET. |
| 109 | + # GitHub will cryptographically sign the request payload with this secret. We will do the same, and if the results |
| 110 | + # match, then we know that the request is from GitHub (or, at least, from someone who knows the secret!) |
| 111 | + # If they don't match, this request is an attack, and we should reject it. |
| 112 | + # The signature comes in with header x-hub-signature, and looks like "sha1=123456" |
| 113 | + # We should take the left hand side as the signature method, and the right hand side as the |
| 114 | + # HMAC digest (the signature) itself. |
| 115 | + their_signature_header = request.env['HTTP_X_HUB_SIGNATURE'] || 'sha1=' |
| 116 | + method, their_digest = their_signature_header.split('=') |
| 117 | + our_digest = OpenSSL::HMAC.hexdigest(method, WEBHOOK_SECRET, payload_raw) |
| 118 | + halt 401 unless their_digest == our_digest |
| 119 | + |
| 120 | + # Determine what kind of event this is, and take action as appropriate |
| 121 | + # TODO we assume that GitHub will always provide an X-GITHUB-EVENT header in this case, which is a reasonable |
| 122 | + # assumption, however we should probably be more careful! |
| 123 | + logger.debug "---- recevied event #{request.env['HTTP_X_GITHUB_EVENT']}" |
| 124 | + logger.debug "---- action #{payload['action']}" unless payload['action'].nil? |
| 125 | + |
| 126 | + case request.env['HTTP_X_GITHUB_EVENT'] |
| 127 | + when :the_event_that_i_care_about |
| 128 | + # Add code here to handle the event that you care about! |
| 129 | + handle_the_event_that_i_care_about(payload) |
| 130 | + end |
| 131 | + |
| 132 | + 'ok' # we have to return _something_ ;) |
| 133 | + end |
| 134 | + |
| 135 | + |
| 136 | +########## Helpers |
| 137 | +# |
| 138 | +# These functions are going to help us do some tasks that we don't want clogging up the happy paths above, or |
| 139 | +# that need to be done repeatedly. You can add anything you like here, really! |
| 140 | +# |
| 141 | + |
| 142 | + helpers do |
| 143 | + |
| 144 | + # This is our handler for the event that you care about! Of course, you'll want to change the name to reflect |
| 145 | + # the actual event name! But this is where you will add code to process the event. |
| 146 | + def handle_the_event_that_i_care_about(payload) |
| 147 | + logger.debug 'Handling the event that we care about!' |
| 148 | + true |
| 149 | + end |
| 150 | + |
| 151 | + end |
| 152 | + |
| 153 | + |
| 154 | +# Finally some logic to let us run this server directly from the commandline, or with Rack |
| 155 | +# Don't worry too much about this code ;) But, for the curious: |
| 156 | +# $0 is the executed file |
| 157 | +# __FILE__ is the current file |
| 158 | +# If they are the same—that is, we are running this file directly, call the Sinatra run method |
| 159 | + run! if __FILE__ == $0 |
| 160 | +end |
0 commit comments