Skip to content

Commit 5fc2a9e

Browse files
committed
WIP migrating to ghapi for continuous API support
1 parent 713b364 commit 5fc2a9e

File tree

2 files changed

+194
-78
lines changed

2 files changed

+194
-78
lines changed

Pipfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ tox-gh-actions = "*"
1717

1818
[packages]
1919
"github3.py" = "*"
20+
ghapi = "*"
2021
flask = "*"
2122
pyyaml = "*"
2223
ldap3 = "*"

githubapp/core.py

Lines changed: 193 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,67 @@
11
"""
22
Flask extension for rapid GitHub app development
33
"""
4-
import os.path
54
import hmac
5+
import time
66
import logging
7-
import distutils
7+
import requests
8+
import jwt
9+
10+
from flask import abort, current_app, jsonify, make_response, request, _app_ctx_stack
11+
from distutils.util import strtobool
12+
from os import environ
13+
from ghapi.all import GhApi
814

9-
from flask import abort, current_app, jsonify, request, _app_ctx_stack
10-
from github3 import GitHub, GitHubEnterprise
1115

1216
LOG = logging.getLogger(__name__)
1317

1418
STATUS_FUNC_CALLED = "HIT"
1519
STATUS_NO_FUNC_CALLED = "MISS"
1620

1721

18-
class GitHubApp(object):
22+
class GitHubAppError(Exception):
23+
pass
24+
25+
26+
class GitHubAppValidationError(Exception):
27+
pass
28+
29+
30+
class GitHubAppBadCredentials(Exception):
31+
pass
32+
33+
34+
class GithubUnauthorized(Exception):
35+
pass
36+
37+
38+
class GithubAppUnkownObject(Exception):
39+
pass
40+
41+
42+
class InstallationAuthorization:
1943
"""
20-
The GitHubApp object provides the central interface for interacting GitHub hooks
44+
This class represents InstallationAuthorizations
45+
"""
46+
47+
def __init__(self, token, expires_at):
48+
self.token = token
49+
self.expires_at = expires_at
50+
51+
def token(self):
52+
return self._token
53+
54+
def expires_at(self):
55+
return self._expires_at
56+
57+
def expired(self):
58+
if self.expires_at:
59+
return time.time() > self.expires_at
60+
return False
61+
62+
63+
class GitHubApp(object):
64+
"""The GitHubApp object provides the central interface for interacting GitHub hooks
2165
and creating GitHub app clients.
2266
2367
GitHubApp object allows using the "on" decorator to make GitHub hooks to functions
@@ -29,24 +73,24 @@ class GitHubApp(object):
2973

3074
def __init__(self, app=None):
3175
self._hook_mappings = {}
76+
self._access_token = None
3277
if app is not None:
3378
self.init_app(app)
3479

3580
@staticmethod
3681
def load_env(app):
37-
app.config["GITHUBAPP_ID"] = int(os.environ["APP_ID"])
38-
app.config["GITHUBAPP_SECRET"] = os.environ["WEBHOOK_SECRET"]
39-
if "GHE_HOST" in os.environ:
40-
app.config["GITHUBAPP_URL"] = "https://{}".format(os.environ["GHE_HOST"])
82+
app.config["GITHUBAPP_ID"] = int(environ["APP_ID"])
83+
app.config["GITHUBAPP_SECRET"] = environ["WEBHOOK_SECRET"]
84+
if "GHE_HOST" in environ:
85+
app.config["GITHUBAPP_URL"] = "https://{}".format(environ["GHE_HOST"])
4186
app.config["VERIFY_SSL"] = bool(
42-
distutils.util.strtobool(os.environ.get("VERIFY_SSL", "false"))
87+
strtobool(environ.get("VERIFY_SSL", "false"))
4388
)
44-
with open(os.environ["PRIVATE_KEY_PATH"], "rb") as key_file:
89+
with open(environ["PRIVATE_KEY_PATH"], "rb") as key_file:
4590
app.config["GITHUBAPP_KEY"] = key_file.read()
4691

4792
def init_app(self, app):
48-
"""
49-
Initializes GitHubApp app by setting configuration variables.
93+
"""Initializes GitHubApp app by setting configuration variables.
5094
5195
The GitHubApp instance is given the following configuration variables by calling on Flask's configuration:
5296
@@ -62,7 +106,8 @@ def init_app(self, app):
62106
63107
`GITHUBAPP_SECRET`:
64108
65-
Secret used to secure webhooks as bytes or utf-8 encoded string (required).
109+
Secret used to secure webhooks as bytes or utf-8 encoded string (required). set to `False` to disable
110+
verification (not recommended for production).
66111
Default: None
67112
68113
`GITHUBAPP_URL`:
@@ -75,14 +120,19 @@ def init_app(self, app):
75120
Path used for GitHub hook requests as a string.
76121
Default: '/'
77122
"""
78-
self.load_env(app)
79123
required_settings = ["GITHUBAPP_ID", "GITHUBAPP_KEY", "GITHUBAPP_SECRET"]
80124
for setting in required_settings:
81-
if not app.config.get(setting):
125+
if not setting in app.config:
82126
raise RuntimeError(
83-
"Flask-GitHubApp requires the '%s' config var to be set" % setting
127+
"Flask-GitHubApplication requires the '%s' config var to be set"
128+
% setting
84129
)
85130

131+
if app.config.get("GITHUBAPP_URL"):
132+
self.base_url = app.config.get("GITHUBAPP_URL")
133+
else:
134+
self.base_url = "https://api.github.com"
135+
86136
app.add_url_rule(
87137
app.config.get("GITHUBAPP_ROUTE", "/"),
88138
view_func=self._flask_view_func,
@@ -111,16 +161,6 @@ def secret(self):
111161
def _api_url(self):
112162
return current_app.config["GITHUBAPP_URL"]
113163

114-
@property
115-
def client(self):
116-
"""Unauthenticated GitHub client"""
117-
if current_app.config.get("GITHUBAPP_URL"):
118-
return GitHubEnterprise(
119-
current_app.config["GITHUBAPP_URL"],
120-
verify=current_app.config["VERIFY_SSL"],
121-
)
122-
return GitHub()
123-
124164
@property
125165
def payload(self):
126166
"""GitHub hook payload"""
@@ -132,58 +172,97 @@ def payload(self):
132172
)
133173

134174
@property
135-
def installation_client(self):
175+
def installation_token(self):
176+
return self._access_token
177+
178+
def client(self, installation_id=None):
136179
"""GitHub client authenticated as GitHub app installation"""
137180
ctx = _app_ctx_stack.top
138181
if ctx is not None:
139182
if not hasattr(ctx, "githubapp_installation"):
140-
client = self.client
141-
client.login_as_app_installation(
142-
self.key, self.id, self.payload["installation"]["id"]
143-
)
144-
ctx.githubapp_installation = client
183+
if installation_id is None:
184+
installation_id = self.payload["installation"]["id"]
185+
self._access_token = self.get_access_token(installation_id).token
186+
ctx.githubapp_installation = GhApi(token=self._access_token)
145187
return ctx.githubapp_installation
146188

147-
@property
148-
def app_client(self):
149-
"""GitHub client authenticated as GitHub app"""
150-
ctx = _app_ctx_stack.top
151-
if ctx is not None:
152-
if not hasattr(ctx, "githubapp_app"):
153-
client = self.client
154-
client.login_as_app(self.key, self.id)
155-
ctx.githubapp_app = client
156-
return ctx.githubapp_app
157-
158-
@property
159-
def installation_token(self):
189+
def _create_jwt(self, expiration=60):
160190
"""
191+
Creates a signed JWT, valid for 60 seconds by default.
192+
The expiration can be extended beyond this, to a maximum of 600 seconds.
193+
:param expiration: int
194+
:return string:
195+
"""
196+
now = int(time.time())
197+
payload = {"iat": now, "exp": now + expiration, "iss": self.id}
198+
encrypted = jwt.encode(payload, key=self.key, algorithm="RS256")
199+
200+
if isinstance(encrypted, bytes):
201+
encrypted = encrypted.decode("utf-8")
202+
return encrypted
161203

162-
:return:
204+
def get_access_token(self, installation_id, user_id=None):
163205
"""
164-
return self.installation_client.session.auth.token
206+
Get an access token for the given installation id.
207+
POSTs https://api.github.com/app/installations/<installation_id>/access_tokens
208+
:param user_id: int
209+
:param installation_id: int
210+
:return: :class:`github.InstallationAuthorization.InstallationAuthorization`
211+
"""
212+
body = {}
213+
if user_id:
214+
body = {"user_id": user_id}
215+
response = requests.post(
216+
f"{self.base_url}/app/installations/{installation_id}/access_tokens",
217+
headers={
218+
"Authorization": f"Bearer {self._create_jwt()}",
219+
"Accept": "application/vnd.github.v3+json",
220+
"User-Agent": "Flask-GithubApplication/Python",
221+
},
222+
json=body,
223+
)
224+
if response.status_code == 201:
225+
return InstallationAuthorization(
226+
token=response.json()["token"], expires_at=response.json()["expires_at"]
227+
)
228+
elif response.status_code == 403:
229+
raise GitHubAppBadCredentials(
230+
status=response.status_code, data=response.text
231+
)
232+
elif response.status_code == 404:
233+
raise GithubAppUnkownObject(status=response.status_code, data=response.text)
234+
raise Exception(status=response.status_code, data=response.text)
165235

166-
def app_installation(self, installation_id=None):
236+
def list_installations(self, per_page=30, page=1):
167237
"""
168-
Login as installation when triggered on a non-webhook event.
169-
This is necessary for scheduling tasks
170-
:param installation_id:
171-
:return:
238+
GETs https://api.github.com/app/installations
239+
:return: :obj: `list` of installations
172240
"""
173-
"""GitHub client authenticated as GitHub app installation"""
174-
ctx = _app_ctx_stack.top
175-
if installation_id is None:
176-
raise RuntimeError("Installation ID is not specified.")
177-
if ctx is not None:
178-
if not hasattr(ctx, "githubapp_installation"):
179-
client = self.client
180-
client.login_as_app_installation(self.key, self.id, installation_id)
181-
ctx.githubapp_installation = client
182-
return ctx.githubapp_installation
241+
params = {"page": page, "per_page": per_page}
242+
243+
response = requests.get(
244+
f"{self.base_url}/app/installations",
245+
headers={
246+
"Authorization": f"Bearer {self._create_jwt()}",
247+
"Accept": "application/vnd.github.v3+json",
248+
"User-Agent": "Flask-GithubApplication/python",
249+
},
250+
params=params,
251+
)
252+
if response.status_code == 200:
253+
return response.json()
254+
elif response.status_code == 401:
255+
raise GithubUnauthorized(status=response.status_code, data=response.text)
256+
elif response.status_code == 403:
257+
raise GitHubAppBadCredentials(
258+
status=response.status_code, data=response.text
259+
)
260+
elif response.status_code == 404:
261+
raise GithubAppUnkownObject(status=response.status_code, data=response.text)
262+
raise Exception(status=response.status_code, data=response.text)
183263

184264
def on(self, event_action):
185-
"""
186-
Decorator routes a GitHub hook to the wrapped function.
265+
"""Decorator routes a GitHub hook to the wrapped function.
187266
188267
Functions decorated as a hook recipient are registered as the function for the given GitHub event.
189268
@@ -192,7 +271,7 @@ def cruel_closer():
192271
owner = github_app.payload['repository']['owner']['login']
193272
repo = github_app.payload['repository']['name']
194273
num = github_app.payload['issue']['id']
195-
issue = github_app.installation_client.issue(owner, repo, num)
274+
issue = github_app.client.issue(owner, repo, num)
196275
issue.create_comment('Could not replicate.')
197276
issue.close()
198277
@@ -213,14 +292,41 @@ def decorator(f):
213292

214293
return decorator
215294

295+
def _validate_request(self):
296+
if not request.is_json:
297+
raise GitHubAppValidationError(
298+
"Invalid HTTP Content-Type header for JSON body "
299+
"(must be application/json or application/*+json)."
300+
)
301+
try:
302+
request.json
303+
except BadRequest:
304+
raise GitHubAppValidationError("Invalid HTTP body (must be JSON).")
305+
306+
event = request.headers.get("X-GitHub-Event")
307+
308+
if event is None:
309+
raise GitHubAppValidationError("Missing X-GitHub-Event HTTP header.")
310+
311+
action = request.json.get("action")
312+
313+
return event, action
314+
216315
def _flask_view_func(self):
217316
functions_to_call = []
218317
calls = {}
219318

220-
event = request.headers["X-GitHub-Event"]
221-
action = request.json.get("action")
319+
try:
320+
event, action = self._validate_request()
321+
except GitHubAppValidationError as e:
322+
LOG.error(e)
323+
error_response = make_response(
324+
jsonify(status="ERROR", description=str(e)), 400
325+
)
326+
return abort(error_response)
222327

223-
self._verify_webhook()
328+
if current_app.config["GITHUBAPP_SECRET"] is not False:
329+
self._verify_webhook()
224330

225331
if event in self._hook_mappings:
226332
functions_to_call += self._hook_mappings[event]
@@ -239,15 +345,24 @@ def _flask_view_func(self):
239345
return jsonify({"status": status, "calls": calls})
240346

241347
def _verify_webhook(self):
242-
hub_signature = "X-HUB-SIGNATURE"
243-
if hub_signature not in request.headers:
244-
LOG.warning("Github Hook Signature not found.")
245-
abort(400)
246-
247-
signature = request.headers[hub_signature].split("=")[1]
348+
signature_header = "X-Hub-Signature-256"
349+
signature_header_legacy = "X-Hub-Signature"
350+
351+
if request.headers.get(signature_header):
352+
signature = request.headers[signature_header].split("=")[1]
353+
digestmod = "sha256"
354+
elif request.headers.get(signature_header_legacy):
355+
signature = request.headers[signature_header_legacy].split("=")[1]
356+
digestmod = "sha1"
357+
else:
358+
LOG.warning(
359+
"Signature header missing. Configure your GitHub App with a secret or set GITHUBAPP_SECRET"
360+
"to False to disable verification."
361+
)
362+
return abort(400)
248363

249-
mac = hmac.new(self.secret, msg=request.data, digestmod="sha1")
364+
mac = hmac.new(self.secret, msg=request.data, digestmod=digestmod)
250365

251366
if not hmac.compare_digest(mac.hexdigest(), signature):
252367
LOG.warning("GitHub hook signature verification failed.")
253-
abort(400)
368+
return abort(400)

0 commit comments

Comments
 (0)