Skip to content

Commit b53e012

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

2 files changed

Lines changed: 185 additions & 84 deletions

File tree

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: 184 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,64 @@
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
87

9-
from flask import abort, current_app, jsonify, request, _app_ctx_stack
10-
from github3 import GitHub, GitHubEnterprise
8+
from flask import abort, current_app, jsonify, make_response, request, _app_ctx_stack
9+
from ghapi.all import GhApi
10+
import requests
11+
import jwt
1112

1213
LOG = logging.getLogger(__name__)
1314

1415
STATUS_FUNC_CALLED = "HIT"
1516
STATUS_NO_FUNC_CALLED = "MISS"
1617

1718

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

3071
def __init__(self, app=None):
3172
self._hook_mappings = {}
73+
self._access_token = None
3274
if app is not None:
3375
self.init_app(app)
3476

35-
@staticmethod
36-
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"])
41-
app.config["VERIFY_SSL"] = bool(
42-
distutils.util.strtobool(os.environ.get("VERIFY_SSL", "false"))
43-
)
44-
with open(os.environ["PRIVATE_KEY_PATH"], "rb") as key_file:
45-
app.config["GITHUBAPP_KEY"] = key_file.read()
46-
4777
def init_app(self, app):
48-
"""
49-
Initializes GitHubApp app by setting configuration variables.
78+
"""Initializes GitHubApp app by setting configuration variables.
5079
5180
The GitHubApp instance is given the following configuration variables by calling on Flask's configuration:
5281
@@ -62,7 +91,8 @@ def init_app(self, app):
6291
6392
`GITHUBAPP_SECRET`:
6493
65-
Secret used to secure webhooks as bytes or utf-8 encoded string (required).
94+
Secret used to secure webhooks as bytes or utf-8 encoded string (required). set to `False` to disable
95+
verification (not recommended for production).
6696
Default: None
6797
6898
`GITHUBAPP_URL`:
@@ -75,14 +105,19 @@ def init_app(self, app):
75105
Path used for GitHub hook requests as a string.
76106
Default: '/'
77107
"""
78-
self.load_env(app)
79108
required_settings = ["GITHUBAPP_ID", "GITHUBAPP_KEY", "GITHUBAPP_SECRET"]
80109
for setting in required_settings:
81-
if not app.config.get(setting):
110+
if not setting in app.config:
82111
raise RuntimeError(
83-
"Flask-GitHubApp requires the '%s' config var to be set" % setting
112+
"Flask-GitHubApplication requires the '%s' config var to be set"
113+
% setting
84114
)
85115

116+
if app.config.get("GITHUBAPP_URL"):
117+
self.base_url = app.config.get("GITHUBAPP_URL")
118+
else:
119+
self.base_url = "https://api.github.com"
120+
86121
app.add_url_rule(
87122
app.config.get("GITHUBAPP_ROUTE", "/"),
88123
view_func=self._flask_view_func,
@@ -111,16 +146,6 @@ def secret(self):
111146
def _api_url(self):
112147
return current_app.config["GITHUBAPP_URL"]
113148

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-
124149
@property
125150
def payload(self):
126151
"""GitHub hook payload"""
@@ -132,58 +157,97 @@ def payload(self):
132157
)
133158

134159
@property
135-
def installation_client(self):
160+
def installation_token(self):
161+
return self._access_token
162+
163+
def client(self, installation_id=None):
136164
"""GitHub client authenticated as GitHub app installation"""
137165
ctx = _app_ctx_stack.top
138166
if ctx is not None:
139167
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
168+
if installation_id is None:
169+
installation_id = self.payload["installation"]["id"]
170+
self._access_token = self.get_access_token(installation_id).token
171+
ctx.githubapp_installation = GhApi(token=self._access_token)
145172
return ctx.githubapp_installation
146173

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):
174+
def _create_jwt(self, expiration=60):
175+
"""
176+
Creates a signed JWT, valid for 60 seconds by default.
177+
The expiration can be extended beyond this, to a maximum of 600 seconds.
178+
:param expiration: int
179+
:return string:
160180
"""
181+
now = int(time.time())
182+
payload = {"iat": now, "exp": now + expiration, "iss": self.id}
183+
encrypted = jwt.encode(payload, key=self.key, algorithm="RS256")
161184

162-
:return:
185+
if isinstance(encrypted, bytes):
186+
encrypted = encrypted.decode("utf-8")
187+
return encrypted
188+
189+
def get_access_token(self, installation_id, user_id=None):
190+
"""
191+
Get an access token for the given installation id.
192+
POSTs https://api.github.com/app/installations/<installation_id>/access_tokens
193+
:param user_id: int
194+
:param installation_id: int
195+
:return: :class:`github.InstallationAuthorization.InstallationAuthorization`
163196
"""
164-
return self.installation_client.session.auth.token
197+
body = {}
198+
if user_id:
199+
body = {"user_id": user_id}
200+
response = requests.post(
201+
f"{self.base_url}/app/installations/{installation_id}/access_tokens",
202+
headers={
203+
"Authorization": f"Bearer {self._create_jwt()}",
204+
"Accept": "application/vnd.github.v3+json",
205+
"User-Agent": "Flask-GithubApplication/Python",
206+
},
207+
json=body,
208+
)
209+
if response.status_code == 201:
210+
return InstallationAuthorization(
211+
token=response.json()["token"], expires_at=response.json()["expires_at"]
212+
)
213+
elif response.status_code == 403:
214+
raise GitHubAppBadCredentials(
215+
status=response.status_code, data=response.text
216+
)
217+
elif response.status_code == 404:
218+
raise GithubAppUnkownObject(status=response.status_code, data=response.text)
219+
raise Exception(status=response.status_code, data=response.text)
165220

166-
def app_installation(self, installation_id=None):
221+
def list_installations(self, per_page=30, page=1):
167222
"""
168-
Login as installation when triggered on a non-webhook event.
169-
This is necessary for scheduling tasks
170-
:param installation_id:
171-
:return:
223+
GETs https://api.github.com/app/installations
224+
:return: :obj: `list` of installations
172225
"""
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
226+
params = {"page": page, "per_page": per_page}
227+
228+
response = requests.get(
229+
f"{self.base_url}/app/installations",
230+
headers={
231+
"Authorization": f"Bearer {self._create_jwt()}",
232+
"Accept": "application/vnd.github.v3+json",
233+
"User-Agent": "Flask-GithubApplication/python",
234+
},
235+
params=params,
236+
)
237+
if response.status_code == 200:
238+
return response.json()
239+
elif response.status_code == 401:
240+
raise GithubUnauthorized(status=response.status_code, data=response.text)
241+
elif response.status_code == 403:
242+
raise GitHubAppBadCredentials(
243+
status=response.status_code, data=response.text
244+
)
245+
elif response.status_code == 404:
246+
raise GithubAppUnkownObject(status=response.status_code, data=response.text)
247+
raise Exception(status=response.status_code, data=response.text)
183248

184249
def on(self, event_action):
185-
"""
186-
Decorator routes a GitHub hook to the wrapped function.
250+
"""Decorator routes a GitHub hook to the wrapped function.
187251
188252
Functions decorated as a hook recipient are registered as the function for the given GitHub event.
189253
@@ -192,7 +256,7 @@ def cruel_closer():
192256
owner = github_app.payload['repository']['owner']['login']
193257
repo = github_app.payload['repository']['name']
194258
num = github_app.payload['issue']['id']
195-
issue = github_app.installation_client.issue(owner, repo, num)
259+
issue = github_app.client.issue(owner, repo, num)
196260
issue.create_comment('Could not replicate.')
197261
issue.close()
198262
@@ -213,14 +277,41 @@ def decorator(f):
213277

214278
return decorator
215279

280+
def _validate_request(self):
281+
if not request.is_json:
282+
raise GitHubAppValidationError(
283+
"Invalid HTTP Content-Type header for JSON body "
284+
"(must be application/json or application/*+json)."
285+
)
286+
try:
287+
request.json
288+
except BadRequest:
289+
raise GitHubAppValidationError("Invalid HTTP body (must be JSON).")
290+
291+
event = request.headers.get("X-GitHub-Event")
292+
293+
if event is None:
294+
raise GitHubAppValidationError("Missing X-GitHub-Event HTTP header.")
295+
296+
action = request.json.get("action")
297+
298+
return event, action
299+
216300
def _flask_view_func(self):
217301
functions_to_call = []
218302
calls = {}
219303

220-
event = request.headers["X-GitHub-Event"]
221-
action = request.json.get("action")
304+
try:
305+
event, action = self._validate_request()
306+
except GitHubAppValidationError as e:
307+
LOG.error(e)
308+
error_response = make_response(
309+
jsonify(status="ERROR", description=str(e)), 400
310+
)
311+
return abort(error_response)
222312

223-
self._verify_webhook()
313+
if current_app.config["GITHUBAPP_SECRET"] is not False:
314+
self._verify_webhook()
224315

225316
if event in self._hook_mappings:
226317
functions_to_call += self._hook_mappings[event]
@@ -239,15 +330,24 @@ def _flask_view_func(self):
239330
return jsonify({"status": status, "calls": calls})
240331

241332
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]
333+
signature_header = "X-Hub-Signature-256"
334+
signature_header_legacy = "X-Hub-Signature"
335+
336+
if request.headers.get(signature_header):
337+
signature = request.headers[signature_header].split("=")[1]
338+
digestmod = "sha256"
339+
elif request.headers.get(signature_header_legacy):
340+
signature = request.headers[signature_header_legacy].split("=")[1]
341+
digestmod = "sha1"
342+
else:
343+
LOG.warning(
344+
"Signature header missing. Configure your GitHub App with a secret or set GITHUBAPP_SECRET"
345+
"to False to disable verification."
346+
)
347+
return abort(400)
248348

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

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

0 commit comments

Comments
 (0)