Skip to content

Commit 54ced28

Browse files
committed
Fix incoming payload parsing, cleanup, and add logging
1 parent 7de216b commit 54ced28

File tree

4 files changed

+136
-80
lines changed

4 files changed

+136
-80
lines changed

readthedocs/core/views/hooks.py

+39-32
Original file line numberDiff line numberDiff line change
@@ -141,20 +141,24 @@ def github_build(request):
141141
142142
This will search for projects matching either a stripped down HTTP or SSH
143143
URL. The search is error prone, use the API v2 webhook for new webhooks.
144+
145+
Old webhooks may not have specified the content type to POST with, and
146+
therefore can use ``application/x-www-form-urlencoded`` to pass the JSON
147+
payload. More information on the API docs here:
148+
https://developer.github.com/webhooks/creating/#content-type
144149
"""
145150
if request.method == 'POST':
146151
try:
147-
data = json.loads(request.body)
148-
except (ValueError, TypeError):
149-
log.error('Invalid GitHub webhook payload', exc_info=True)
150-
return HttpResponse('Invalid request', status=400)
151-
http_url = data['repository']['url']
152-
http_search_url = http_url.replace('http://', '').replace('https://', '')
153-
ssh_url = data['repository']['ssh_url']
154-
ssh_search_url = ssh_url.replace('git@', '').replace('.git', '')
155-
try:
152+
if request.META['CONTENT_TYPE'] == 'application/x-www-form-urlencoded':
153+
data = json.loads(request.POST.get('payload'))
154+
else:
155+
data = json.loads(request.body)
156+
http_url = data['repository']['url']
157+
http_search_url = http_url.replace('http://', '').replace('https://', '')
158+
ssh_url = data['repository']['ssh_url']
159+
ssh_search_url = ssh_url.replace('git@', '').replace('.git', '')
156160
branches = [data['ref'].replace('refs/heads/', '')]
157-
except KeyError:
161+
except (ValueError, TypeError, KeyError):
158162
log.error('Invalid GitHub webhook payload', exc_info=True)
159163
return HttpResponse('Invalid request', status=400)
160164
try:
@@ -178,7 +182,7 @@ def github_build(request):
178182
log.error('Project match not found: url=%s', http_search_url)
179183
return HttpResponseNotFound('Project not found')
180184
else:
181-
return HttpResponse('Method not allowed', status=405)
185+
return HttpResponse('Method not allowed, POST is required', status=405)
182186

183187

184188
@csrf_exempt
@@ -191,12 +195,12 @@ def gitlab_build(request):
191195
if request.method == 'POST':
192196
try:
193197
data = json.loads(request.body)
194-
except (ValueError, TypeError):
198+
url = data['project']['http_url']
199+
search_url = re.sub(r'^https?://(.*?)(?:\.git|)$', '\\1', url)
200+
branches = [data['ref'].replace('refs/heads/', '')]
201+
except (ValueError, TypeError, KeyError):
195202
log.error('Invalid GitLab webhook payload', exc_info=True)
196203
return HttpResponse('Invalid request', status=400)
197-
url = data['project']['http_url']
198-
search_url = re.sub(r'^https?://(.*?)(?:\.git|)$', '\\1', url)
199-
branches = [data['ref'].replace('refs/heads/', '')]
200204
log.info(
201205
'GitLab webhook search: url=%s branches=%s',
202206
search_url,
@@ -209,49 +213,52 @@ def gitlab_build(request):
209213
log.error('Project match not found: url=%s', search_url)
210214
return HttpResponseNotFound('Project match not found')
211215
else:
212-
return HttpResponse('Method not allowed', status=405)
216+
return HttpResponse('Method not allowed, POST is required', status=405)
213217

214218

215219
@csrf_exempt
216220
def bitbucket_build(request):
217221
"""Consume webhooks from multiple versions of Bitbucket's API
218222
223+
New webhooks are set up with v2, but v1 webhooks will still point to this
224+
endpoint. There are also "services" that point here and submit
225+
``application/x-www-form-urlencoded`` data.
226+
219227
API v1
220228
https://confluence.atlassian.com/bitbucket/events-resources-296095220.html
221229
222230
API v2
223231
https://confluence.atlassian.com/bitbucket/event-payloads-740262817.html#EventPayloads-Push
232+
233+
Services
234+
https://confluence.atlassian.com/bitbucket/post-service-management-223216518.html
224235
"""
225236
if request.method == 'POST':
226237
try:
227-
data = json.loads(request.body)
228-
except (TypeError, ValueError):
229-
log.error('Invalid Bitbucket webhook payload', exc_info=True)
230-
return HttpResponse('Invalid request', status=400)
238+
if request.META['CONTENT_TYPE'] == 'application/x-www-form-urlencoded':
239+
data = json.loads(request.POST.get('payload'))
240+
else:
241+
data = json.loads(request.body)
231242

232-
version = 2 if request.META.get('HTTP_USER_AGENT') == 'Bitbucket-Webhooks/2.0' else 1
233-
if version == 1:
234-
try:
243+
version = 2 if request.META.get('HTTP_USER_AGENT') == 'Bitbucket-Webhooks/2.0' else 1
244+
if version == 1:
235245
branches = [commit.get('branch', '')
236246
for commit in data['commits']]
237247
repository = data['repository']
238248
search_url = 'bitbucket.org{0}'.format(
239249
repository['absolute_url'].rstrip('/')
240250
)
241-
except KeyError:
242-
log.error('Invalid Bitbucket webhook payload', exc_info=True)
243-
return HttpResponse('Invalid request', status=400)
244-
elif version == 2:
245-
try:
251+
elif version == 2:
246252
changes = data['push']['changes']
247253
branches = [change['new']['name']
248254
for change in changes]
249255
search_url = 'bitbucket.org/{0}'.format(
250256
data['repository']['full_name']
251257
)
252-
except KeyError:
253-
log.error('Invalid Bitbucket webhook payload', exc_info=True)
254-
return HttpResponse('Invalid request', status=400)
258+
except (TypeError, ValueError, KeyError):
259+
log.error('Invalid Bitbucket webhook payload', exc_info=True)
260+
return HttpResponse('Invalid request', status=400)
261+
255262
log.info(
256263
'Bitbucket webhook search: url=%s branches=%s',
257264
search_url,
@@ -265,7 +272,7 @@ def bitbucket_build(request):
265272
log.error('Project match not found: url=%s', search_url)
266273
return HttpResponseNotFound('Project match not found')
267274
else:
268-
return HttpResponse('Method not allowed', status=405)
275+
return HttpResponse('Method not allowed, POST is required', status=405)
269276

270277

271278
@csrf_exempt

readthedocs/restapi/views/integrations.py

+37-46
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import logging
2+
13
from rest_framework import permissions
24
from rest_framework.views import APIView
35
from rest_framework.renderers import JSONRenderer
@@ -13,6 +15,8 @@
1315
from readthedocs.projects.models import Project
1416

1517

18+
log = logging.getLogger(__name__)
19+
1620
GITHUB_PUSH = 'push'
1721
GITLAB_PUSH = 'push'
1822
BITBUCKET_PUSH = 'repo:push'
@@ -27,23 +31,41 @@ def post(self, request, project_slug, format=None):
2731
try:
2832
project = Project.objects.get(slug=project_slug)
2933
resp = self.handle_webhook(request, project, request.data)
34+
if resp is None:
35+
log.info('Unhandled webhook event')
36+
resp = {'detail': 'Unhandled webhook event'}
3037
except Project.DoesNotExist:
3138
raise Http404('Project does not exist')
3239
return Response(resp)
3340

3441
def handle_webhook(self, request, project, data=None):
35-
"""Handle webhook payload
42+
"""Handle webhook payload"""
43+
raise NotImplementedError
3644

37-
If a build is triggered from this method, return a JSON response with
38-
the following::
45+
def get_response_push(self, project, branches):
46+
"""Build branches on push events and return API response
47+
48+
Return a JSON response with the following::
3949
4050
{
4151
"build_triggered": true,
4252
"project": "project_name",
4353
"versions": [...]
4454
}
55+
56+
:param project: Project instance
57+
:type project: Project
58+
:param branches: List of branch names to build
59+
:type branches: list(str)
4560
"""
46-
raise NotImplementedError
61+
to_build, not_building = build_branches(project, branches)
62+
if not_building:
63+
log.info('Skipping project branches: project=%s branches=%s',
64+
project, branches)
65+
triggered = True if to_build else False
66+
return {'build_triggered': triggered,
67+
'project': project.slug,
68+
'versions': to_build}
4769

4870

4971
class GitHubWebhookView(WebhookMixin, APIView):
@@ -60,32 +82,19 @@ class GitHubWebhookView(WebhookMixin, APIView):
6082
"ref": "branch-name",
6183
...
6284
}
63-
64-
If a build is triggered, this will return JSON with the following::
65-
66-
{
67-
"build_triggered": true,
68-
"project": "project_name",
69-
"versions": [...]
70-
}
7185
"""
7286

7387
def handle_webhook(self, request, project, data=None):
7488
# Get event and trigger other webhook events
7589
event = request.META.get('HTTP_X_GITHUB_EVENT', 'push')
7690
webhook_github.send(Project, project=project, data=data, event=event)
7791
# Handle push events and trigger builds
78-
try:
79-
branches = [request.data['ref'].replace('refs/heads/', '')]
80-
except KeyError:
81-
raise ParseError('Parameter "ref" is required')
8292
if event == GITHUB_PUSH:
83-
to_build, not_building = build_branches(project, branches)
84-
triggered = True if to_build else False
85-
return {'build_triggered': triggered,
86-
'project': project.slug,
87-
'versions': to_build}
88-
return {'build_triggered': False}
93+
try:
94+
branches = [request.data['ref'].replace('refs/heads/', '')]
95+
return self.get_response_push(project, branches)
96+
except KeyError:
97+
raise ParseError('Parameter "ref" is required')
8998

9099

91100
class GitLabWebhookView(WebhookMixin, APIView):
@@ -101,32 +110,19 @@ class GitLabWebhookView(WebhookMixin, APIView):
101110
"ref": "branch-name",
102111
...
103112
}
104-
105-
If a build is triggered, this will return JSON with the following::
106-
107-
{
108-
"build_triggered": true,
109-
"project": "project_name",
110-
"versions": [...]
111-
}
112113
"""
113114

114115
def handle_webhook(self, request, project, data=None):
115116
# Get event and trigger other webhook events
116117
event = data.get('object_kind', GITLAB_PUSH)
117118
webhook_gitlab.send(Project, project=project, data=data, event=event)
118119
# Handle push events and trigger builds
119-
try:
120-
branches = [request.data['ref'].replace('refs/heads/', '')]
121-
except KeyError:
122-
raise ParseError('Parameter "ref" is required')
123120
if event == GITLAB_PUSH:
124-
to_build, not_building = build_branches(project, branches)
125-
triggered = True if to_build else False
126-
return {'build_triggered': triggered,
127-
'project': project.slug,
128-
'versions': to_build}
129-
return {'build_triggered': False}
121+
try:
122+
branches = [request.data['ref'].replace('refs/heads/', '')]
123+
return self.get_response_push(project, branches)
124+
except KeyError:
125+
raise ParseError('Parameter "ref" is required')
130126

131127

132128
class BitbucketWebhookView(WebhookMixin, APIView):
@@ -162,11 +158,6 @@ def handle_webhook(self, request, project, data=None):
162158
changes = data['push']['changes']
163159
branches = [change['new']['name']
164160
for change in changes]
161+
return self.get_response_push(project, branches)
165162
except KeyError:
166163
raise ParseError('Invalid request')
167-
to_build, not_building = build_branches(project, branches)
168-
triggered = True if to_build else False
169-
return {'build_triggered': triggered,
170-
'project': project.slug,
171-
'versions': to_build}
172-
return {'build_triggered': False}

readthedocs/rtd_tests/tests/test_api.py

+35
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,18 @@ def test_github_webhook(self, trigger_build):
324324
mock.call(force=True, version=mock.ANY, project=self.project)
325325
])
326326

327+
def test_github_invalid_webhook(self, trigger_build):
328+
"""GitHub webhook unhandled event"""
329+
client = APIClient()
330+
resp = client.post(
331+
'/api/v2/webhook/github/{0}/'.format(self.project.slug),
332+
{'foo': 'bar'},
333+
format='json',
334+
HTTP_X_GITHUB_EVENT='pull_request',
335+
)
336+
self.assertEqual(resp.status_code, 200)
337+
self.assertEqual(resp.data['detail'], 'Unhandled webhook event')
338+
327339
def test_gitlab_webhook(self, trigger_build):
328340
"""GitLab webhook API"""
329341
client = APIClient()
@@ -344,6 +356,17 @@ def test_gitlab_webhook(self, trigger_build):
344356
mock.call(force=True, version=mock.ANY, project=self.project)
345357
])
346358

359+
def test_gitlab_invalid_webhook(self, trigger_build):
360+
"""GitLab webhook unhandled event"""
361+
client = APIClient()
362+
resp = client.post(
363+
'/api/v2/webhook/gitlab/{0}/'.format(self.project.slug),
364+
{'object_kind': 'pull_request'},
365+
format='json',
366+
)
367+
self.assertEqual(resp.status_code, 200)
368+
self.assertEqual(resp.data['detail'], 'Unhandled webhook event')
369+
347370
def test_bitbucket_webhook(self, trigger_build):
348371
"""Bitbucket webhook API"""
349372
client = APIClient()
@@ -379,3 +402,15 @@ def test_bitbucket_webhook(self, trigger_build):
379402
trigger_build.assert_has_calls([
380403
mock.call(force=True, version=mock.ANY, project=self.project)
381404
])
405+
406+
def test_bitbucket_invalid_webhook(self, trigger_build):
407+
"""Bitbucket webhook unhandled event"""
408+
client = APIClient()
409+
resp = client.post(
410+
'/api/v2/webhook/bitbucket/{0}/'.format(self.project.slug),
411+
{'foo': 'bar'},
412+
format='json',
413+
HTTP_X_EVENT_KEY='pull_request'
414+
)
415+
self.assertEqual(resp.status_code, 200)
416+
self.assertEqual(resp.data['detail'], 'Unhandled webhook event')

readthedocs/rtd_tests/tests/test_post_commit_hooks.py

+25-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
from django.test import TestCase
21
import json
32
import logging
4-
import mock
3+
from urllib import urlencode
54

5+
import mock
66
from django_dynamic_fixture import get
7+
from django.test import TestCase
78

89
from readthedocs.builds.models import Version
910
from readthedocs.projects.models import Project
@@ -209,6 +210,17 @@ def setUp(self):
209210
}
210211
}
211212

213+
def test_post_types(self):
214+
"""Ensure various POST formats"""
215+
r = self.client.post('/github/',
216+
data=json.dumps(self.payload),
217+
content_type='application/json')
218+
self.assertEqual(r.status_code, 200)
219+
r = self.client.post('/github/',
220+
data=urlencode({'payload': json.dumps(self.payload)}),
221+
content_type='application/x-www-form-urlencoded')
222+
self.assertEqual(r.status_code, 200)
223+
212224
def test_github_upper_case_repo(self):
213225
"""
214226
Test the github post commit hook will build properly with upper case
@@ -404,6 +416,17 @@ def setUp(self):
404416
"user": "marcus"
405417
}
406418

419+
def test_post_types(self):
420+
"""Ensure various POST formats"""
421+
r = self.client.post('/bitbucket/',
422+
data=json.dumps(self.hg_payload),
423+
content_type='application/json')
424+
self.assertEqual(r.status_code, 200)
425+
r = self.client.post('/bitbucket/',
426+
data=urlencode({'payload': json.dumps(self.hg_payload)}),
427+
content_type='application/x-www-form-urlencoded')
428+
self.assertEqual(r.status_code, 200)
429+
407430
def test_bitbucket_post_commit(self):
408431
r = self.client.post('/bitbucket/', data=json.dumps(self.hg_payload),
409432
content_type='application/json')

0 commit comments

Comments
 (0)