##// END OF EJS Templates
requests: added a default timeout for operation calling the endpoint url....
marcink -
r2950:d70230db default
parent child Browse files
Show More
@@ -1,460 +1,460 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import time
22 22 import collections
23 23 import datetime
24 24 import formencode
25 25 import formencode.htmlfill
26 26 import logging
27 27 import urlparse
28 28 import requests
29 29
30 30 from pyramid.httpexceptions import HTTPFound
31 31 from pyramid.view import view_config
32 32
33 33 from rhodecode.apps._base import BaseAppView
34 34 from rhodecode.authentication.base import authenticate, HTTP_TYPE
35 35 from rhodecode.events import UserRegistered, trigger
36 36 from rhodecode.lib import helpers as h
37 37 from rhodecode.lib import audit_logger
38 38 from rhodecode.lib.auth import (
39 39 AuthUser, HasPermissionAnyDecorator, CSRFRequired)
40 40 from rhodecode.lib.base import get_ip_addr
41 41 from rhodecode.lib.exceptions import UserCreationError
42 42 from rhodecode.lib.utils2 import safe_str
43 43 from rhodecode.model.db import User, UserApiKeys
44 44 from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm
45 45 from rhodecode.model.meta import Session
46 46 from rhodecode.model.auth_token import AuthTokenModel
47 47 from rhodecode.model.settings import SettingsModel
48 48 from rhodecode.model.user import UserModel
49 49 from rhodecode.translation import _
50 50
51 51
52 52 log = logging.getLogger(__name__)
53 53
54 54 CaptchaData = collections.namedtuple(
55 55 'CaptchaData', 'active, private_key, public_key')
56 56
57 57
58 58 def _store_user_in_session(session, username, remember=False):
59 59 user = User.get_by_username(username, case_insensitive=True)
60 60 auth_user = AuthUser(user.user_id)
61 61 auth_user.set_authenticated()
62 62 cs = auth_user.get_cookie_store()
63 63 session['rhodecode_user'] = cs
64 64 user.update_lastlogin()
65 65 Session().commit()
66 66
67 67 # If they want to be remembered, update the cookie
68 68 if remember:
69 69 _year = (datetime.datetime.now() +
70 70 datetime.timedelta(seconds=60 * 60 * 24 * 365))
71 71 session._set_cookie_expires(_year)
72 72
73 73 session.save()
74 74
75 75 safe_cs = cs.copy()
76 76 safe_cs['password'] = '****'
77 77 log.info('user %s is now authenticated and stored in '
78 78 'session, session attrs %s', username, safe_cs)
79 79
80 80 # dumps session attrs back to cookie
81 81 session._update_cookie_out()
82 82 # we set new cookie
83 83 headers = None
84 84 if session.request['set_cookie']:
85 85 # send set-cookie headers back to response to update cookie
86 86 headers = [('Set-Cookie', session.request['cookie_out'])]
87 87 return headers
88 88
89 89
90 90 def get_came_from(request):
91 91 came_from = safe_str(request.GET.get('came_from', ''))
92 92 parsed = urlparse.urlparse(came_from)
93 93 allowed_schemes = ['http', 'https']
94 94 default_came_from = h.route_path('home')
95 95 if parsed.scheme and parsed.scheme not in allowed_schemes:
96 96 log.error('Suspicious URL scheme detected %s for url %s' %
97 97 (parsed.scheme, parsed))
98 98 came_from = default_came_from
99 99 elif parsed.netloc and request.host != parsed.netloc:
100 100 log.error('Suspicious NETLOC detected %s for url %s server url '
101 101 'is: %s' % (parsed.netloc, parsed, request.host))
102 102 came_from = default_came_from
103 103 elif any(bad_str in parsed.path for bad_str in ('\r', '\n')):
104 104 log.error('Header injection detected `%s` for url %s server url ' %
105 105 (parsed.path, parsed))
106 106 came_from = default_came_from
107 107
108 108 return came_from or default_came_from
109 109
110 110
111 111 class LoginView(BaseAppView):
112 112
113 113 def load_default_context(self):
114 114 c = self._get_local_tmpl_context()
115 115 c.came_from = get_came_from(self.request)
116 116
117 117 return c
118 118
119 119 def _get_captcha_data(self):
120 120 settings = SettingsModel().get_all_settings()
121 121 private_key = settings.get('rhodecode_captcha_private_key')
122 122 public_key = settings.get('rhodecode_captcha_public_key')
123 123 active = bool(private_key)
124 124 return CaptchaData(
125 125 active=active, private_key=private_key, public_key=public_key)
126 126
127 127 def validate_captcha(self, private_key):
128 128
129 129 captcha_rs = self.request.POST.get('g-recaptcha-response')
130 130 url = "https://www.google.com/recaptcha/api/siteverify"
131 131 params = {
132 132 'secret': private_key,
133 133 'response': captcha_rs,
134 134 'remoteip': get_ip_addr(self.request.environ)
135 135 }
136 verify_rs = requests.get(url, params=params, verify=True)
136 verify_rs = requests.get(url, params=params, verify=True, timeout=60)
137 137 verify_rs = verify_rs.json()
138 138 captcha_status = verify_rs.get('success', False)
139 139 captcha_errors = verify_rs.get('error-codes', [])
140 140 if not isinstance(captcha_errors, list):
141 141 captcha_errors = [captcha_errors]
142 142 captcha_errors = ', '.join(captcha_errors)
143 143 captcha_message = ''
144 144 if captcha_status is False:
145 145 captcha_message = "Bad captcha. Errors: {}".format(
146 146 captcha_errors)
147 147
148 148 return captcha_status, captcha_message
149 149
150 150 @view_config(
151 151 route_name='login', request_method='GET',
152 152 renderer='rhodecode:templates/login.mako')
153 153 def login(self):
154 154 c = self.load_default_context()
155 155 auth_user = self._rhodecode_user
156 156
157 157 # redirect if already logged in
158 158 if (auth_user.is_authenticated and
159 159 not auth_user.is_default and auth_user.ip_allowed):
160 160 raise HTTPFound(c.came_from)
161 161
162 162 # check if we use headers plugin, and try to login using it.
163 163 try:
164 164 log.debug('Running PRE-AUTH for headers based authentication')
165 165 auth_info = authenticate(
166 166 '', '', self.request.environ, HTTP_TYPE, skip_missing=True)
167 167 if auth_info:
168 168 headers = _store_user_in_session(
169 169 self.session, auth_info.get('username'))
170 170 raise HTTPFound(c.came_from, headers=headers)
171 171 except UserCreationError as e:
172 172 log.error(e)
173 173 h.flash(e, category='error')
174 174
175 175 return self._get_template_context(c)
176 176
177 177 @view_config(
178 178 route_name='login', request_method='POST',
179 179 renderer='rhodecode:templates/login.mako')
180 180 def login_post(self):
181 181 c = self.load_default_context()
182 182
183 183 login_form = LoginForm(self.request.translate)()
184 184
185 185 try:
186 186 self.session.invalidate()
187 187 form_result = login_form.to_python(self.request.POST)
188 188 # form checks for username/password, now we're authenticated
189 189 headers = _store_user_in_session(
190 190 self.session,
191 191 username=form_result['username'],
192 192 remember=form_result['remember'])
193 193 log.debug('Redirecting to "%s" after login.', c.came_from)
194 194
195 195 audit_user = audit_logger.UserWrap(
196 196 username=self.request.POST.get('username'),
197 197 ip_addr=self.request.remote_addr)
198 198 action_data = {'user_agent': self.request.user_agent}
199 199 audit_logger.store_web(
200 200 'user.login.success', action_data=action_data,
201 201 user=audit_user, commit=True)
202 202
203 203 raise HTTPFound(c.came_from, headers=headers)
204 204 except formencode.Invalid as errors:
205 205 defaults = errors.value
206 206 # remove password from filling in form again
207 207 defaults.pop('password', None)
208 208 render_ctx = {
209 209 'errors': errors.error_dict,
210 210 'defaults': defaults,
211 211 }
212 212
213 213 audit_user = audit_logger.UserWrap(
214 214 username=self.request.POST.get('username'),
215 215 ip_addr=self.request.remote_addr)
216 216 action_data = {'user_agent': self.request.user_agent}
217 217 audit_logger.store_web(
218 218 'user.login.failure', action_data=action_data,
219 219 user=audit_user, commit=True)
220 220 return self._get_template_context(c, **render_ctx)
221 221
222 222 except UserCreationError as e:
223 223 # headers auth or other auth functions that create users on
224 224 # the fly can throw this exception signaling that there's issue
225 225 # with user creation, explanation should be provided in
226 226 # Exception itself
227 227 h.flash(e, category='error')
228 228 return self._get_template_context(c)
229 229
230 230 @CSRFRequired()
231 231 @view_config(route_name='logout', request_method='POST')
232 232 def logout(self):
233 233 auth_user = self._rhodecode_user
234 234 log.info('Deleting session for user: `%s`', auth_user)
235 235
236 236 action_data = {'user_agent': self.request.user_agent}
237 237 audit_logger.store_web(
238 238 'user.logout', action_data=action_data,
239 239 user=auth_user, commit=True)
240 240 self.session.delete()
241 241 return HTTPFound(h.route_path('home'))
242 242
243 243 @HasPermissionAnyDecorator(
244 244 'hg.admin', 'hg.register.auto_activate', 'hg.register.manual_activate')
245 245 @view_config(
246 246 route_name='register', request_method='GET',
247 247 renderer='rhodecode:templates/register.mako',)
248 248 def register(self, defaults=None, errors=None):
249 249 c = self.load_default_context()
250 250 defaults = defaults or {}
251 251 errors = errors or {}
252 252
253 253 settings = SettingsModel().get_all_settings()
254 254 register_message = settings.get('rhodecode_register_message') or ''
255 255 captcha = self._get_captcha_data()
256 256 auto_active = 'hg.register.auto_activate' in User.get_default_user()\
257 257 .AuthUser().permissions['global']
258 258
259 259 render_ctx = self._get_template_context(c)
260 260 render_ctx.update({
261 261 'defaults': defaults,
262 262 'errors': errors,
263 263 'auto_active': auto_active,
264 264 'captcha_active': captcha.active,
265 265 'captcha_public_key': captcha.public_key,
266 266 'register_message': register_message,
267 267 })
268 268 return render_ctx
269 269
270 270 @HasPermissionAnyDecorator(
271 271 'hg.admin', 'hg.register.auto_activate', 'hg.register.manual_activate')
272 272 @view_config(
273 273 route_name='register', request_method='POST',
274 274 renderer='rhodecode:templates/register.mako')
275 275 def register_post(self):
276 276 self.load_default_context()
277 277 captcha = self._get_captcha_data()
278 278 auto_active = 'hg.register.auto_activate' in User.get_default_user()\
279 279 .AuthUser().permissions['global']
280 280
281 281 register_form = RegisterForm(self.request.translate)()
282 282 try:
283 283
284 284 form_result = register_form.to_python(self.request.POST)
285 285 form_result['active'] = auto_active
286 286
287 287 if captcha.active:
288 288 captcha_status, captcha_message = self.validate_captcha(
289 289 captcha.private_key)
290 290
291 291 if not captcha_status:
292 292 _value = form_result
293 293 _msg = _('Bad captcha')
294 294 error_dict = {'recaptcha_field': captcha_message}
295 295 raise formencode.Invalid(
296 296 _msg, _value, None, error_dict=error_dict)
297 297
298 298 new_user = UserModel().create_registration(form_result)
299 299
300 300 action_data = {'data': new_user.get_api_data(),
301 301 'user_agent': self.request.user_agent}
302 302
303 303 audit_user = audit_logger.UserWrap(
304 304 username=new_user.username,
305 305 user_id=new_user.user_id,
306 306 ip_addr=self.request.remote_addr)
307 307
308 308 audit_logger.store_web(
309 309 'user.register', action_data=action_data,
310 310 user=audit_user)
311 311
312 312 event = UserRegistered(user=new_user, session=self.session)
313 313 trigger(event)
314 314 h.flash(
315 315 _('You have successfully registered with RhodeCode'),
316 316 category='success')
317 317 Session().commit()
318 318
319 319 redirect_ro = self.request.route_path('login')
320 320 raise HTTPFound(redirect_ro)
321 321
322 322 except formencode.Invalid as errors:
323 323 errors.value.pop('password', None)
324 324 errors.value.pop('password_confirmation', None)
325 325 return self.register(
326 326 defaults=errors.value, errors=errors.error_dict)
327 327
328 328 except UserCreationError as e:
329 329 # container auth or other auth functions that create users on
330 330 # the fly can throw this exception signaling that there's issue
331 331 # with user creation, explanation should be provided in
332 332 # Exception itself
333 333 h.flash(e, category='error')
334 334 return self.register()
335 335
336 336 @view_config(
337 337 route_name='reset_password', request_method=('GET', 'POST'),
338 338 renderer='rhodecode:templates/password_reset.mako')
339 339 def password_reset(self):
340 340 c = self.load_default_context()
341 341 captcha = self._get_captcha_data()
342 342
343 343 template_context = {
344 344 'captcha_active': captcha.active,
345 345 'captcha_public_key': captcha.public_key,
346 346 'defaults': {},
347 347 'errors': {},
348 348 }
349 349
350 350 # always send implicit message to prevent from discovery of
351 351 # matching emails
352 352 msg = _('If such email exists, a password reset link was sent to it.')
353 353
354 354 if self.request.POST:
355 355 if h.HasPermissionAny('hg.password_reset.disabled')():
356 356 _email = self.request.POST.get('email', '')
357 357 log.error('Failed attempt to reset password for `%s`.', _email)
358 358 h.flash(_('Password reset has been disabled.'),
359 359 category='error')
360 360 return HTTPFound(self.request.route_path('reset_password'))
361 361
362 362 password_reset_form = PasswordResetForm(self.request.translate)()
363 363 try:
364 364 form_result = password_reset_form.to_python(
365 365 self.request.POST)
366 366 user_email = form_result['email']
367 367
368 368 if captcha.active:
369 369 captcha_status, captcha_message = self.validate_captcha(
370 370 captcha.private_key)
371 371
372 372 if not captcha_status:
373 373 _value = form_result
374 374 _msg = _('Bad captcha')
375 375 error_dict = {'recaptcha_field': captcha_message}
376 376 raise formencode.Invalid(
377 377 _msg, _value, None, error_dict=error_dict)
378 378
379 379 # Generate reset URL and send mail.
380 380 user = User.get_by_email(user_email)
381 381
382 382 # generate password reset token that expires in 10minutes
383 383 desc = 'Generated token for password reset from {}'.format(
384 384 datetime.datetime.now().isoformat())
385 385 reset_token = AuthTokenModel().create(
386 386 user, lifetime=10,
387 387 description=desc,
388 388 role=UserApiKeys.ROLE_PASSWORD_RESET)
389 389 Session().commit()
390 390
391 391 log.debug('Successfully created password recovery token')
392 392 password_reset_url = self.request.route_url(
393 393 'reset_password_confirmation',
394 394 _query={'key': reset_token.api_key})
395 395 UserModel().reset_password_link(
396 396 form_result, password_reset_url)
397 397 # Display success message and redirect.
398 398 h.flash(msg, category='success')
399 399
400 400 action_data = {'email': user_email,
401 401 'user_agent': self.request.user_agent}
402 402 audit_logger.store_web(
403 403 'user.password.reset_request', action_data=action_data,
404 404 user=self._rhodecode_user, commit=True)
405 405 return HTTPFound(self.request.route_path('reset_password'))
406 406
407 407 except formencode.Invalid as errors:
408 408 template_context.update({
409 409 'defaults': errors.value,
410 410 'errors': errors.error_dict,
411 411 })
412 412 if not self.request.POST.get('email'):
413 413 # case of empty email, we want to report that
414 414 return self._get_template_context(c, **template_context)
415 415
416 416 if 'recaptcha_field' in errors.error_dict:
417 417 # case of failed captcha
418 418 return self._get_template_context(c, **template_context)
419 419
420 420 log.debug('faking response on invalid password reset')
421 421 # make this take 2s, to prevent brute forcing.
422 422 time.sleep(2)
423 423 h.flash(msg, category='success')
424 424 return HTTPFound(self.request.route_path('reset_password'))
425 425
426 426 return self._get_template_context(c, **template_context)
427 427
428 428 @view_config(route_name='reset_password_confirmation',
429 429 request_method='GET')
430 430 def password_reset_confirmation(self):
431 431 self.load_default_context()
432 432 if self.request.GET and self.request.GET.get('key'):
433 433 # make this take 2s, to prevent brute forcing.
434 434 time.sleep(2)
435 435
436 436 token = AuthTokenModel().get_auth_token(
437 437 self.request.GET.get('key'))
438 438
439 439 # verify token is the correct role
440 440 if token is None or token.role != UserApiKeys.ROLE_PASSWORD_RESET:
441 441 log.debug('Got token with role:%s expected is %s',
442 442 getattr(token, 'role', 'EMPTY_TOKEN'),
443 443 UserApiKeys.ROLE_PASSWORD_RESET)
444 444 h.flash(
445 445 _('Given reset token is invalid'), category='error')
446 446 return HTTPFound(self.request.route_path('reset_password'))
447 447
448 448 try:
449 449 owner = token.user
450 450 data = {'email': owner.email, 'token': token.api_key}
451 451 UserModel().reset_password(data)
452 452 h.flash(
453 453 _('Your password reset was successful, '
454 454 'a new password has been sent to your email'),
455 455 category='success')
456 456 except Exception as e:
457 457 log.error(e)
458 458 return HTTPFound(self.request.route_path('reset_password'))
459 459
460 460 return HTTPFound(self.request.route_path('login'))
@@ -1,251 +1,253 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 from __future__ import unicode_literals
22 22 import deform
23 23 import logging
24 24 import requests
25 25 import colander
26 26 import textwrap
27 27 from mako.template import Template
28 28 from rhodecode import events
29 29 from rhodecode.translation import _
30 30 from rhodecode.lib import helpers as h
31 31 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
32 32 from rhodecode.lib.colander_utils import strip_whitespace
33 33 from rhodecode.integrations.types.base import (
34 34 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback)
35 35
36 36 log = logging.getLogger(__name__)
37 37
38 38
39 39 class HipchatSettingsSchema(colander.Schema):
40 40 color_choices = [
41 41 ('yellow', _('Yellow')),
42 42 ('red', _('Red')),
43 43 ('green', _('Green')),
44 44 ('purple', _('Purple')),
45 45 ('gray', _('Gray')),
46 46 ]
47 47
48 48 server_url = colander.SchemaNode(
49 49 colander.String(),
50 50 title=_('Hipchat server URL'),
51 51 description=_('Hipchat integration url.'),
52 52 default='',
53 53 preparer=strip_whitespace,
54 54 validator=colander.url,
55 55 widget=deform.widget.TextInputWidget(
56 56 placeholder='https://?.hipchat.com/v2/room/?/notification?auth_token=?',
57 57 ),
58 58 )
59 59 notify = colander.SchemaNode(
60 60 colander.Bool(),
61 61 title=_('Notify'),
62 62 description=_('Make a notification to the users in room.'),
63 63 missing=False,
64 64 default=False,
65 65 )
66 66 color = colander.SchemaNode(
67 67 colander.String(),
68 68 title=_('Color'),
69 69 description=_('Background color of message.'),
70 70 missing='',
71 71 validator=colander.OneOf([x[0] for x in color_choices]),
72 72 widget=deform.widget.Select2Widget(
73 73 values=color_choices,
74 74 ),
75 75 )
76 76
77 77
78 78 repo_push_template = Template('''
79 79 <b>${data['actor']['username']}</b> pushed to repo <a href="${data['repo']['url']}">${data['repo']['repo_name']}</a>:
80 80 <br>
81 81 <ul>
82 82 %for branch, branch_commits in branches_commits.items():
83 83 <li>
84 84 % if branch:
85 85 <a href="${branch_commits['branch']['url']}">branch: ${branch_commits['branch']['name']}</a>
86 86 % else:
87 87 to trunk
88 88 % endif
89 89 <ul>
90 90 % for commit in branch_commits['commits']:
91 91 <li><a href="${commit['url']}">${commit['short_id']}</a> - ${commit['message_html']}</li>
92 92 % endfor
93 93 </ul>
94 94 </li>
95 95 %endfor
96 96 ''')
97 97
98 98
99 99 class HipchatIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
100 100 key = 'hipchat'
101 101 display_name = _('Hipchat')
102 102 description = _('Send events such as repo pushes and pull requests to '
103 103 'your hipchat channel.')
104 104
105 105 @classmethod
106 106 def icon(cls):
107 107 return '''<?xml version="1.0" encoding="utf-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve"><g><g transform="translate(0.000000,511.000000) scale(0.100000,-0.100000)"><path fill="#205281" d="M4197.1,4662.4c-1661.5-260.4-3018-1171.6-3682.6-2473.3C219.9,1613.6,100,1120.3,100,462.6c0-1014,376.8-1918.4,1127-2699.4C2326.7-3377.6,3878.5-3898.3,5701-3730.5l486.5,44.5l208.9-123.3c637.2-373.4,1551.8-640.6,2240.4-650.9c304.9-6.9,335.7,0,417.9,75.4c185,174.7,147.3,411.1-89.1,548.1c-315.2,181.6-620,544.7-733.1,870.1l-51.4,157.6l472.7,472.7c349.4,349.4,520.7,551.5,657.7,774.2c784.5,1281.2,784.5,2788.5,0,4052.6c-236.4,376.8-794.8,966-1178.4,1236.7c-572.1,407.7-1264.1,709.1-1993.7,870.1c-267.2,58.2-479.6,75.4-1038,82.2C4714.4,4686.4,4310.2,4679.6,4197.1,4662.4z M5947.6,3740.9c1856.7-380.3,3127.6-1709.4,3127.6-3275c0-1000.3-534.4-1949.2-1466.2-2600.1c-188.4-133.6-287.8-226.1-301.5-284.4c-41.1-157.6,263.8-938.6,397.4-1020.8c20.5-10.3,34.3-44.5,34.3-75.4c0-167.8-811.9,195.3-1363.4,609.8l-181.6,137l-332.3-58.2c-445.3-78.8-1281.2-78.8-1702.6,0C2796-2569.2,1734.1-1832.6,1220.2-801.5C983.8-318.5,905,51.5,929,613.3c27.4,640.6,243.2,1192.1,685.1,1740.3c620,770.8,1661.5,1305.2,2822.8,1452.5C4806.9,3854,5553.7,3819.7,5947.6,3740.9z"/><path fill="#205281" d="M2381.5-345.9c-75.4-106.2-68.5-167.8,34.3-322c332.3-500.2,1010.6-928.4,1760.8-1120.2c417.9-106.2,1226.4-106.2,1644.3,0c712.5,181.6,1270.9,517.3,1685.4,1014C7681-561.7,7715.3-424.7,7616-325.4c-89.1,89.1-167.9,65.1-431.7-133.6c-835.8-630.3-2028-856.4-3086.5-585.8C3683.3-938.6,3142-685,2830.3-448.7C2576.8-253.4,2463.7-229.4,2381.5-345.9z"/></g></g><!-- Svg Vector Icons : http://www.onlinewebfonts.com/icon --></svg>'''
108 108
109 109 valid_events = [
110 110 events.PullRequestCloseEvent,
111 111 events.PullRequestMergeEvent,
112 112 events.PullRequestUpdateEvent,
113 113 events.PullRequestCommentEvent,
114 114 events.PullRequestReviewEvent,
115 115 events.PullRequestCreateEvent,
116 116 events.RepoPushEvent,
117 117 events.RepoCreateEvent,
118 118 ]
119 119
120 120 def send_event(self, event):
121 121 if event.__class__ not in self.valid_events:
122 122 log.debug('event not valid: %r' % event)
123 123 return
124 124
125 125 if event.name not in self.settings['events']:
126 126 log.debug('event ignored: %r' % event)
127 127 return
128 128
129 129 data = event.as_dict()
130 130
131 131 text = '<b>%s<b> caused a <b>%s</b> event' % (
132 132 data['actor']['username'], event.name)
133 133
134 134 log.debug('handling hipchat event for %s' % event.name)
135 135
136 136 if isinstance(event, events.PullRequestCommentEvent):
137 137 text = self.format_pull_request_comment_event(event, data)
138 138 elif isinstance(event, events.PullRequestReviewEvent):
139 139 text = self.format_pull_request_review_event(event, data)
140 140 elif isinstance(event, events.PullRequestEvent):
141 141 text = self.format_pull_request_event(event, data)
142 142 elif isinstance(event, events.RepoPushEvent):
143 143 text = self.format_repo_push_event(data)
144 144 elif isinstance(event, events.RepoCreateEvent):
145 145 text = self.format_repo_create_event(data)
146 146 else:
147 147 log.error('unhandled event type: %r' % event)
148 148
149 149 run_task(post_text_to_hipchat, self.settings, text)
150 150
151 151 def settings_schema(self):
152 152 schema = HipchatSettingsSchema()
153 153 schema.add(colander.SchemaNode(
154 154 colander.Set(),
155 155 widget=deform.widget.CheckboxChoiceWidget(
156 156 values=sorted(
157 157 [(e.name, e.display_name) for e in self.valid_events]
158 158 )
159 159 ),
160 160 description="Events activated for this integration",
161 161 name='events'
162 162 ))
163 163
164 164 return schema
165 165
166 166 def format_pull_request_comment_event(self, event, data):
167 167 comment_text = data['comment']['text']
168 168 if len(comment_text) > 200:
169 169 comment_text = '{comment_text}<a href="{comment_url}">...<a/>'.format(
170 170 comment_text=h.html_escape(comment_text[:200]),
171 171 comment_url=data['comment']['url'],
172 172 )
173 173
174 174 comment_status = ''
175 175 if data['comment']['status']:
176 176 comment_status = '[{}]: '.format(data['comment']['status'])
177 177
178 178 return (textwrap.dedent(
179 179 '''
180 180 {user} commented on pull request <a href="{pr_url}">{number}</a> - {pr_title}:
181 181 >>> {comment_status}{comment_text}
182 182 ''').format(
183 183 comment_status=comment_status,
184 184 user=data['actor']['username'],
185 185 number=data['pullrequest']['pull_request_id'],
186 186 pr_url=data['pullrequest']['url'],
187 187 pr_status=data['pullrequest']['status'],
188 188 pr_title=h.html_escape(data['pullrequest']['title']),
189 189 comment_text=h.html_escape(comment_text)
190 190 )
191 191 )
192 192
193 193 def format_pull_request_review_event(self, event, data):
194 194 return (textwrap.dedent(
195 195 '''
196 196 Status changed to {pr_status} for pull request <a href="{pr_url}">#{number}</a> - {pr_title}
197 197 ''').format(
198 198 user=data['actor']['username'],
199 199 number=data['pullrequest']['pull_request_id'],
200 200 pr_url=data['pullrequest']['url'],
201 201 pr_status=data['pullrequest']['status'],
202 202 pr_title=h.html_escape(data['pullrequest']['title']),
203 203 )
204 204 )
205 205
206 206 def format_pull_request_event(self, event, data):
207 207 action = {
208 208 events.PullRequestCloseEvent: 'closed',
209 209 events.PullRequestMergeEvent: 'merged',
210 210 events.PullRequestUpdateEvent: 'updated',
211 211 events.PullRequestCreateEvent: 'created',
212 212 }.get(event.__class__, str(event.__class__))
213 213
214 214 return ('Pull request <a href="{url}">#{number}</a> - {title} '
215 215 '{action} by <b>{user}</b>').format(
216 216 user=data['actor']['username'],
217 217 number=data['pullrequest']['pull_request_id'],
218 218 url=data['pullrequest']['url'],
219 219 title=h.html_escape(data['pullrequest']['title']),
220 220 action=action
221 221 )
222 222
223 223 def format_repo_push_event(self, data):
224 224 branches_commits = self.aggregate_branch_data(
225 225 data['push']['branches'], data['push']['commits'])
226 226
227 227 result = render_with_traceback(
228 228 repo_push_template,
229 229 data=data,
230 230 branches_commits=branches_commits,
231 231 )
232 232 return result
233 233
234 234 def format_repo_create_event(self, data):
235 235 return '<a href="{}">{}</a> ({}) repository created by <b>{}</b>'.format(
236 236 data['repo']['url'],
237 237 h.html_escape(data['repo']['repo_name']),
238 238 data['repo']['repo_type'],
239 239 data['actor']['username'],
240 240 )
241 241
242 242
243 243 @async_task(ignore_result=True, base=RequestContextTask)
244 244 def post_text_to_hipchat(settings, text):
245 245 log.debug('sending %s to hipchat %s' % (text, settings['server_url']))
246 resp = requests.post(settings['server_url'], json={
246 json_message = {
247 247 "message": text,
248 248 "color": settings.get('color', 'yellow'),
249 249 "notify": settings.get('notify', False),
250 })
250 }
251
252 resp = requests.post(settings['server_url'], json=json_message, timeout=60)
251 253 resp.raise_for_status() # raise exception on a failed request
@@ -1,350 +1,350 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 from __future__ import unicode_literals
22 22 import re
23 23 import time
24 24 import textwrap
25 25 import logging
26 26
27 27 import deform
28 28 import requests
29 29 import colander
30 30 from mako.template import Template
31 31
32 32 from rhodecode import events
33 33 from rhodecode.translation import _
34 34 from rhodecode.lib import helpers as h
35 35 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
36 36 from rhodecode.lib.colander_utils import strip_whitespace
37 37 from rhodecode.integrations.types.base import (
38 38 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback)
39 39
40 40 log = logging.getLogger(__name__)
41 41
42 42
43 43 class SlackSettingsSchema(colander.Schema):
44 44 service = colander.SchemaNode(
45 45 colander.String(),
46 46 title=_('Slack service URL'),
47 47 description=h.literal(_(
48 48 'This can be setup at the '
49 49 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
50 50 'slack app manager</a>')),
51 51 default='',
52 52 preparer=strip_whitespace,
53 53 validator=colander.url,
54 54 widget=deform.widget.TextInputWidget(
55 55 placeholder='https://hooks.slack.com/services/...',
56 56 ),
57 57 )
58 58 username = colander.SchemaNode(
59 59 colander.String(),
60 60 title=_('Username'),
61 61 description=_('Username to show notifications coming from.'),
62 62 missing='Rhodecode',
63 63 preparer=strip_whitespace,
64 64 widget=deform.widget.TextInputWidget(
65 65 placeholder='Rhodecode'
66 66 ),
67 67 )
68 68 channel = colander.SchemaNode(
69 69 colander.String(),
70 70 title=_('Channel'),
71 71 description=_('Channel to send notifications to.'),
72 72 missing='',
73 73 preparer=strip_whitespace,
74 74 widget=deform.widget.TextInputWidget(
75 75 placeholder='#general'
76 76 ),
77 77 )
78 78 icon_emoji = colander.SchemaNode(
79 79 colander.String(),
80 80 title=_('Emoji'),
81 81 description=_('Emoji to use eg. :studio_microphone:'),
82 82 missing='',
83 83 preparer=strip_whitespace,
84 84 widget=deform.widget.TextInputWidget(
85 85 placeholder=':studio_microphone:'
86 86 ),
87 87 )
88 88
89 89
90 90 class SlackIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
91 91 key = 'slack'
92 92 display_name = _('Slack')
93 93 description = _('Send events such as repo pushes and pull requests to '
94 94 'your slack channel.')
95 95
96 96 @classmethod
97 97 def icon(cls):
98 98 return '''<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid"><g><path d="M165.963541,15.8384262 C162.07318,3.86308197 149.212328,-2.69009836 137.239082,1.20236066 C125.263738,5.09272131 118.710557,17.9535738 122.603016,29.9268197 L181.550164,211.292328 C185.597902,222.478689 197.682361,228.765377 209.282098,225.426885 C221.381246,221.943607 228.756984,209.093246 224.896,197.21023 C224.749115,196.756984 165.963541,15.8384262 165.963541,15.8384262" fill="#DFA22F"></path><path d="M74.6260984,45.515541 C70.7336393,33.5422951 57.8727869,26.9891148 45.899541,30.8794754 C33.9241967,34.7698361 27.3710164,47.6306885 31.2634754,59.6060328 L90.210623,240.971541 C94.2583607,252.157902 106.34282,258.44459 117.942557,255.104 C130.041705,251.62282 137.417443,238.772459 133.556459,226.887344 C133.409574,226.436197 74.6260984,45.515541 74.6260984,45.515541" fill="#3CB187"></path><path d="M240.161574,166.045377 C252.136918,162.155016 258.688,149.294164 254.797639,137.31882 C250.907279,125.345574 238.046426,118.792393 226.07318,122.682754 L44.7076721,181.632 C33.5213115,185.677639 27.234623,197.762098 30.5731148,209.361836 C34.0563934,221.460984 46.9067541,228.836721 58.7897705,224.975738 C59.2430164,224.828852 240.161574,166.045377 240.161574,166.045377" fill="#CE1E5B"></path><path d="M82.507541,217.270557 C94.312918,213.434754 109.528131,208.491016 125.855475,203.186361 C122.019672,191.380984 117.075934,176.163672 111.76918,159.83423 L68.4191475,173.924721 L82.507541,217.270557" fill="#392538"></path><path d="M173.847082,187.591344 C190.235279,182.267803 205.467279,177.31777 217.195016,173.507148 C213.359213,161.70177 208.413377,146.480262 203.106623,130.146623 L159.75659,144.237115 L173.847082,187.591344" fill="#BB242A"></path><path d="M210.484459,74.7058361 C222.457705,70.8154754 229.010885,57.954623 225.120525,45.9792787 C221.230164,34.0060328 208.369311,27.4528525 196.393967,31.3432131 L15.028459,90.292459 C3.84209836,94.3380984 -2.44459016,106.422557 0.896,118.022295 C4.37718033,130.121443 17.227541,137.49718 29.1126557,133.636197 C29.5638033,133.489311 210.484459,74.7058361 210.484459,74.7058361" fill="#72C5CD"></path><path d="M52.8220328,125.933115 C64.6274098,122.097311 79.8468197,117.151475 96.1762623,111.84682 C90.8527213,95.4565246 85.9026885,80.2245246 82.0920656,68.4946885 L38.731541,82.5872787 L52.8220328,125.933115" fill="#248C73"></path><path d="M144.159475,96.256 C160.551869,90.9303607 175.785967,85.9803279 187.515803,82.1676066 C182.190164,65.7752131 177.240131,50.5390164 173.42741,38.807082 L130.068984,52.8996721 L144.159475,96.256" fill="#62803A"></path></g></svg>'''
99 99
100 100 valid_events = [
101 101 events.PullRequestCloseEvent,
102 102 events.PullRequestMergeEvent,
103 103 events.PullRequestUpdateEvent,
104 104 events.PullRequestCommentEvent,
105 105 events.PullRequestReviewEvent,
106 106 events.PullRequestCreateEvent,
107 107 events.RepoPushEvent,
108 108 events.RepoCreateEvent,
109 109 ]
110 110
111 111 def send_event(self, event):
112 112 if event.__class__ not in self.valid_events:
113 113 log.debug('event not valid: %r' % event)
114 114 return
115 115
116 116 if event.name not in self.settings['events']:
117 117 log.debug('event ignored: %r' % event)
118 118 return
119 119
120 120 data = event.as_dict()
121 121
122 122 # defaults
123 123 title = '*%s* caused a *%s* event' % (
124 124 data['actor']['username'], event.name)
125 125 text = '*%s* caused a *%s* event' % (
126 126 data['actor']['username'], event.name)
127 127 fields = None
128 128 overrides = None
129 129
130 130 log.debug('handling slack event for %s' % event.name)
131 131
132 132 if isinstance(event, events.PullRequestCommentEvent):
133 133 (title, text, fields, overrides) \
134 134 = self.format_pull_request_comment_event(event, data)
135 135 elif isinstance(event, events.PullRequestReviewEvent):
136 136 title, text = self.format_pull_request_review_event(event, data)
137 137 elif isinstance(event, events.PullRequestEvent):
138 138 title, text = self.format_pull_request_event(event, data)
139 139 elif isinstance(event, events.RepoPushEvent):
140 140 title, text = self.format_repo_push_event(data)
141 141 elif isinstance(event, events.RepoCreateEvent):
142 142 title, text = self.format_repo_create_event(data)
143 143 else:
144 144 log.error('unhandled event type: %r' % event)
145 145
146 146 run_task(post_text_to_slack, self.settings, title, text, fields, overrides)
147 147
148 148 def settings_schema(self):
149 149 schema = SlackSettingsSchema()
150 150 schema.add(colander.SchemaNode(
151 151 colander.Set(),
152 152 widget=deform.widget.CheckboxChoiceWidget(
153 153 values=sorted(
154 154 [(e.name, e.display_name) for e in self.valid_events]
155 155 )
156 156 ),
157 157 description="Events activated for this integration",
158 158 name='events'
159 159 ))
160 160
161 161 return schema
162 162
163 163 def format_pull_request_comment_event(self, event, data):
164 164 comment_text = data['comment']['text']
165 165 if len(comment_text) > 200:
166 166 comment_text = '<{comment_url}|{comment_text}...>'.format(
167 167 comment_text=comment_text[:200],
168 168 comment_url=data['comment']['url'],
169 169 )
170 170
171 171 fields = None
172 172 overrides = None
173 173 status_text = None
174 174
175 175 if data['comment']['status']:
176 176 status_color = {
177 177 'approved': '#0ac878',
178 178 'rejected': '#e85e4d'}.get(data['comment']['status'])
179 179
180 180 if status_color:
181 181 overrides = {"color": status_color}
182 182
183 183 status_text = data['comment']['status']
184 184
185 185 if data['comment']['file']:
186 186 fields = [
187 187 {
188 188 "title": "file",
189 189 "value": data['comment']['file']
190 190 },
191 191 {
192 192 "title": "line",
193 193 "value": data['comment']['line']
194 194 }
195 195 ]
196 196
197 197 template = Template(textwrap.dedent(r'''
198 198 *${data['actor']['username']}* left ${data['comment']['type']} on pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
199 199 '''))
200 200 title = render_with_traceback(
201 201 template, data=data, comment=event.comment)
202 202
203 203 template = Template(textwrap.dedent(r'''
204 204 *pull request title*: ${pr_title}
205 205 % if status_text:
206 206 *submitted status*: `${status_text}`
207 207 % endif
208 208 >>> ${comment_text}
209 209 '''))
210 210 text = render_with_traceback(
211 211 template,
212 212 comment_text=comment_text,
213 213 pr_title=data['pullrequest']['title'],
214 214 status_text=status_text)
215 215
216 216 return title, text, fields, overrides
217 217
218 218 def format_pull_request_review_event(self, event, data):
219 219 template = Template(textwrap.dedent(r'''
220 220 *${data['actor']['username']}* changed status of pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']} to `${data['pullrequest']['status']}`>:
221 221 '''))
222 222 title = render_with_traceback(template, data=data)
223 223
224 224 template = Template(textwrap.dedent(r'''
225 225 *pull request title*: ${pr_title}
226 226 '''))
227 227 text = render_with_traceback(
228 228 template,
229 229 pr_title=data['pullrequest']['title'])
230 230
231 231 return title, text
232 232
233 233 def format_pull_request_event(self, event, data):
234 234 action = {
235 235 events.PullRequestCloseEvent: 'closed',
236 236 events.PullRequestMergeEvent: 'merged',
237 237 events.PullRequestUpdateEvent: 'updated',
238 238 events.PullRequestCreateEvent: 'created',
239 239 }.get(event.__class__, str(event.__class__))
240 240
241 241 template = Template(textwrap.dedent(r'''
242 242 *${data['actor']['username']}* `${action}` pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
243 243 '''))
244 244 title = render_with_traceback(template, data=data, action=action)
245 245
246 246 template = Template(textwrap.dedent(r'''
247 247 *pull request title*: ${pr_title}
248 248 %if data['pullrequest']['commits']:
249 249 *commits*: ${len(data['pullrequest']['commits'])}
250 250 %endif
251 251 '''))
252 252 text = render_with_traceback(
253 253 template,
254 254 pr_title=data['pullrequest']['title'],
255 255 data=data)
256 256
257 257 return title, text
258 258
259 259 def format_repo_push_event(self, data):
260 260
261 261 branches_commits = self.aggregate_branch_data(
262 262 data['push']['branches'], data['push']['commits'])
263 263
264 264 template = Template(r'''
265 265 *${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
266 266 ''')
267 267 title = render_with_traceback(template, data=data)
268 268
269 269 repo_push_template = Template(textwrap.dedent(r'''
270 270 <%
271 271 def branch_text(branch):
272 272 if branch:
273 273 return 'on branch: <{}|{}>'.format(branch_commits['branch']['url'], branch_commits['branch']['name'])
274 274 else:
275 275 ## case for SVN no branch push...
276 276 return 'to trunk'
277 277 %> \
278 278 % for branch, branch_commits in branches_commits.items():
279 279 ${len(branch_commits['commits'])} ${'commit' if len(branch_commits['commits']) == 1 else 'commits'} ${branch_text(branch)}
280 280 % for commit in branch_commits['commits']:
281 281 `<${commit['url']}|${commit['short_id']}>` - ${commit['message_html']|html_to_slack_links}
282 282 % endfor
283 283 % endfor
284 284 '''))
285 285
286 286 text = render_with_traceback(
287 287 repo_push_template,
288 288 data=data,
289 289 branches_commits=branches_commits,
290 290 html_to_slack_links=html_to_slack_links,
291 291 )
292 292
293 293 return title, text
294 294
295 295 def format_repo_create_event(self, data):
296 296 template = Template(r'''
297 297 *${data['actor']['username']}* created new repository ${data['repo']['repo_name']}:
298 298 ''')
299 299 title = render_with_traceback(template, data=data)
300 300
301 301 template = Template(textwrap.dedent(r'''
302 302 repo_url: ${data['repo']['url']}
303 303 repo_type: ${data['repo']['repo_type']}
304 304 '''))
305 305 text = render_with_traceback(template, data=data)
306 306
307 307 return title, text
308 308
309 309
310 310 def html_to_slack_links(message):
311 311 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
312 312 r'<\1|\2>', message)
313 313
314 314
315 315 @async_task(ignore_result=True, base=RequestContextTask)
316 316 def post_text_to_slack(settings, title, text, fields=None, overrides=None):
317 317 log.debug('sending %s (%s) to slack %s' % (
318 318 title, text, settings['service']))
319 319
320 320 fields = fields or []
321 321 overrides = overrides or {}
322 322
323 323 message_data = {
324 324 "fallback": text,
325 325 "color": "#427cc9",
326 326 "pretext": title,
327 327 #"author_name": "Bobby Tables",
328 328 #"author_link": "http://flickr.com/bobby/",
329 329 #"author_icon": "http://flickr.com/icons/bobby.jpg",
330 330 #"title": "Slack API Documentation",
331 331 #"title_link": "https://api.slack.com/",
332 332 "text": text,
333 333 "fields": fields,
334 334 #"image_url": "http://my-website.com/path/to/image.jpg",
335 335 #"thumb_url": "http://example.com/path/to/thumb.png",
336 336 "footer": "RhodeCode",
337 337 #"footer_icon": "",
338 338 "ts": time.time(),
339 339 "mrkdwn_in": ["pretext", "text"]
340 340 }
341 341 message_data.update(overrides)
342 342 json_message = {
343 343 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:'),
344 344 "channel": settings.get('channel', ''),
345 345 "username": settings.get('username', 'Rhodecode'),
346 346 "attachments": [message_data]
347 347 }
348 348
349 resp = requests.post(settings['service'], json=json_message)
349 resp = requests.post(settings['service'], json=json_message, timeout=60)
350 350 resp.raise_for_status() # raise exception on a failed request
@@ -1,274 +1,274 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 from __future__ import unicode_literals
22 22
23 23 import deform
24 24 import deform.widget
25 25 import logging
26 26 import requests
27 27 import requests.adapters
28 28 import colander
29 29 from requests.packages.urllib3.util.retry import Retry
30 30
31 31 import rhodecode
32 32 from rhodecode import events
33 33 from rhodecode.translation import _
34 34 from rhodecode.integrations.types.base import (
35 35 IntegrationTypeBase, get_auth, get_web_token, get_url_vars,
36 36 WebhookDataHandler, WEBHOOK_URL_VARS)
37 37 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
38 38 from rhodecode.model.validation_schema import widgets
39 39
40 40 log = logging.getLogger(__name__)
41 41
42 42
43 43 # updating this required to update the `common_vars` passed in url calling func
44 44
45 45 URL_VARS = get_url_vars(WEBHOOK_URL_VARS)
46 46
47 47
48 48 class WebhookSettingsSchema(colander.Schema):
49 49 url = colander.SchemaNode(
50 50 colander.String(),
51 51 title=_('Webhook URL'),
52 52 description=
53 53 _('URL to which Webhook should submit data. If used some of the '
54 54 'variables would trigger multiple calls, like ${branch} or '
55 55 '${commit_id}. Webhook will be called as many times as unique '
56 56 'objects in data in such cases.'),
57 57 missing=colander.required,
58 58 required=True,
59 59 validator=colander.url,
60 60 widget=widgets.CodeMirrorWidget(
61 61 help_block_collapsable_name='Show url variables',
62 62 help_block_collapsable=(
63 63 'E.g http://my-serv/trigger_job/${{event_name}}'
64 64 '?PR_ID=${{pull_request_id}}'
65 65 '\nFull list of vars:\n{}'.format(URL_VARS)),
66 66 codemirror_mode='text',
67 67 codemirror_options='{"lineNumbers": false, "lineWrapping": true}'),
68 68 )
69 69 secret_token = colander.SchemaNode(
70 70 colander.String(),
71 71 title=_('Secret Token'),
72 72 description=_('Optional string used to validate received payloads. '
73 73 'It will be sent together with event data in JSON'),
74 74 default='',
75 75 missing='',
76 76 widget=deform.widget.TextInputWidget(
77 77 placeholder='e.g. secret_token'
78 78 ),
79 79 )
80 80 username = colander.SchemaNode(
81 81 colander.String(),
82 82 title=_('Username'),
83 83 description=_('Optional username to authenticate the call.'),
84 84 default='',
85 85 missing='',
86 86 widget=deform.widget.TextInputWidget(
87 87 placeholder='e.g. admin'
88 88 ),
89 89 )
90 90 password = colander.SchemaNode(
91 91 colander.String(),
92 92 title=_('Password'),
93 93 description=_('Optional password to authenticate the call.'),
94 94 default='',
95 95 missing='',
96 96 widget=deform.widget.PasswordWidget(
97 97 placeholder='e.g. secret.',
98 98 redisplay=True,
99 99 ),
100 100 )
101 101 custom_header_key = colander.SchemaNode(
102 102 colander.String(),
103 103 title=_('Custom Header Key'),
104 104 description=_('Custom Header name to be set when calling endpoint.'),
105 105 default='',
106 106 missing='',
107 107 widget=deform.widget.TextInputWidget(
108 108 placeholder='e.g: Authorization'
109 109 ),
110 110 )
111 111 custom_header_val = colander.SchemaNode(
112 112 colander.String(),
113 113 title=_('Custom Header Value'),
114 114 description=_('Custom Header value to be set when calling endpoint.'),
115 115 default='',
116 116 missing='',
117 117 widget=deform.widget.TextInputWidget(
118 118 placeholder='e.g. Basic XxXxXx'
119 119 ),
120 120 )
121 121 method_type = colander.SchemaNode(
122 122 colander.String(),
123 123 title=_('Call Method'),
124 124 description=_('Select if the Webhook call should be made '
125 125 'with POST or GET.'),
126 126 default='post',
127 127 missing='',
128 128 widget=deform.widget.RadioChoiceWidget(
129 129 values=[('get', 'GET'), ('post', 'POST')],
130 130 inline=True
131 131 ),
132 132 )
133 133
134 134
135 135 class WebhookIntegrationType(IntegrationTypeBase):
136 136 key = 'webhook'
137 137 display_name = _('Webhook')
138 138 description = _('send JSON data to a url endpoint')
139 139
140 140 @classmethod
141 141 def icon(cls):
142 142 return '''<?xml version="1.0" encoding="UTF-8" standalone="no"?><svg viewBox="0 0 256 239" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid"><g><path d="M119.540432,100.502743 C108.930124,118.338815 98.7646301,135.611455 88.3876025,152.753617 C85.7226696,157.154315 84.4040417,160.738531 86.5332204,166.333309 C92.4107024,181.787152 84.1193605,196.825836 68.5350381,200.908244 C53.8383677,204.759349 39.5192953,195.099955 36.6032893,179.365384 C34.0194114,165.437749 44.8274148,151.78491 60.1824106,149.608284 C61.4694072,149.424428 62.7821041,149.402681 64.944891,149.240571 C72.469175,136.623655 80.1773157,123.700312 88.3025935,110.073173 C73.611854,95.4654658 64.8677898,78.3885437 66.803227,57.2292132 C68.1712787,42.2715849 74.0527146,29.3462646 84.8033863,18.7517722 C105.393354,-1.53572199 136.805164,-4.82141828 161.048542,10.7510424 C184.333097,25.7086706 194.996783,54.8450075 185.906752,79.7822957 C179.052655,77.9239597 172.151111,76.049808 164.563565,73.9917997 C167.418285,60.1274266 165.306899,47.6765751 155.95591,37.0109123 C149.777932,29.9690049 141.850349,26.2780332 132.835442,24.9178894 C114.764113,22.1877169 97.0209573,33.7983633 91.7563309,51.5355878 C85.7800012,71.6669027 94.8245623,88.1111998 119.540432,100.502743 L119.540432,100.502743 Z" fill="#C73A63"></path><path d="M149.841194,79.4106285 C157.316054,92.5969067 164.905578,105.982857 172.427885,119.246236 C210.44865,107.483365 239.114472,128.530009 249.398582,151.063322 C261.81978,178.282014 253.328765,210.520191 228.933162,227.312431 C203.893073,244.551464 172.226236,241.605803 150.040866,219.46195 C155.694953,214.729124 161.376716,209.974552 167.44794,204.895759 C189.360489,219.088306 208.525074,218.420096 222.753207,201.614016 C234.885769,187.277151 234.622834,165.900356 222.138374,151.863988 C207.730339,135.66681 188.431321,135.172572 165.103273,150.721309 C155.426087,133.553447 145.58086,116.521995 136.210101,99.2295848 C133.05093,93.4015266 129.561608,90.0209366 122.440622,88.7873178 C110.547271,86.7253555 102.868785,76.5124151 102.408155,65.0698097 C101.955433,53.7537294 108.621719,43.5249733 119.04224,39.5394355 C129.363912,35.5914599 141.476705,38.7783085 148.419765,47.554004 C154.093621,54.7244134 155.896602,62.7943365 152.911402,71.6372484 C152.081082,74.1025091 151.00562,76.4886916 149.841194,79.4106285 L149.841194,79.4106285 Z" fill="#4B4B4B"></path><path d="M167.706921,187.209935 L121.936499,187.209935 C117.54964,205.253587 108.074103,219.821756 91.7464461,229.085759 C79.0544063,236.285822 65.3738898,238.72736 50.8136292,236.376762 C24.0061432,232.053165 2.08568567,207.920497 0.156179306,180.745298 C-2.02835403,149.962159 19.1309765,122.599149 47.3341915,116.452801 C49.2814904,123.524363 51.2485589,130.663141 53.1958579,137.716911 C27.3195169,150.919004 18.3639187,167.553089 25.6054984,188.352614 C31.9811726,206.657224 50.0900643,216.690262 69.7528413,212.809503 C89.8327554,208.847688 99.9567329,192.160226 98.7211371,165.37844 C117.75722,165.37844 136.809118,165.180745 155.847178,165.475311 C163.280522,165.591951 169.019617,164.820939 174.620326,158.267339 C183.840836,147.48306 200.811003,148.455721 210.741239,158.640984 C220.88894,169.049642 220.402609,185.79839 209.663799,195.768166 C199.302587,205.38802 182.933414,204.874012 173.240413,194.508846 C171.247644,192.37176 169.677943,189.835329 167.706921,187.209935 L167.706921,187.209935 Z" fill="#4A4A4A"></path></g></svg>'''
143 143
144 144 valid_events = [
145 145 events.PullRequestCloseEvent,
146 146 events.PullRequestMergeEvent,
147 147 events.PullRequestUpdateEvent,
148 148 events.PullRequestCommentEvent,
149 149 events.PullRequestReviewEvent,
150 150 events.PullRequestCreateEvent,
151 151 events.RepoPushEvent,
152 152 events.RepoCreateEvent,
153 153 ]
154 154
155 155 def settings_schema(self):
156 156 schema = WebhookSettingsSchema()
157 157 schema.add(colander.SchemaNode(
158 158 colander.Set(),
159 159 widget=deform.widget.CheckboxChoiceWidget(
160 160 values=sorted(
161 161 [(e.name, e.display_name) for e in self.valid_events]
162 162 )
163 163 ),
164 164 description="Events activated for this integration",
165 165 name='events'
166 166 ))
167 167 return schema
168 168
169 169 def send_event(self, event):
170 170 log.debug(
171 171 'handling event %s with Webhook integration %s', event.name, self)
172 172
173 173 if event.__class__ not in self.valid_events:
174 174 log.debug('event not valid: %r' % event)
175 175 return
176 176
177 177 if event.name not in self.settings['events']:
178 178 log.debug('event ignored: %r' % event)
179 179 return
180 180
181 181 data = event.as_dict()
182 182 template_url = self.settings['url']
183 183
184 184 headers = {}
185 185 head_key = self.settings.get('custom_header_key')
186 186 head_val = self.settings.get('custom_header_val')
187 187 if head_key and head_val:
188 188 headers = {head_key: head_val}
189 189
190 190 handler = WebhookDataHandler(template_url, headers)
191 191
192 192 url_calls = handler(event, data)
193 193 log.debug('webhook: calling following urls: %s',
194 194 [x[0] for x in url_calls])
195 195
196 196 run_task(post_to_webhook, url_calls, self.settings)
197 197
198 198
199 199 @async_task(ignore_result=True, base=RequestContextTask)
200 200 def post_to_webhook(url_calls, settings):
201 201 """
202 202 Example data::
203 203
204 204 {'actor': {'user_id': 2, 'username': u'admin'},
205 205 'actor_ip': u'192.168.157.1',
206 206 'name': 'repo-push',
207 207 'push': {'branches': [{'name': u'default',
208 208 'url': 'http://rc.local:8080/hg-repo/changelog?branch=default'}],
209 209 'commits': [{'author': u'Marcin Kuzminski <marcin@rhodecode.com>',
210 210 'branch': u'default',
211 211 'date': datetime.datetime(2017, 11, 30, 12, 59, 48),
212 212 'issues': [],
213 213 'mentions': [],
214 214 'message': u'commit Thu 30 Nov 2017 13:59:48 CET',
215 215 'message_html': u'commit Thu 30 Nov 2017 13:59:48 CET',
216 216 'message_html_title': u'commit Thu 30 Nov 2017 13:59:48 CET',
217 217 'parents': [{'raw_id': '431b772a5353dad9974b810dd3707d79e3a7f6e0'}],
218 218 'permalink_url': u'http://rc.local:8080/_7/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
219 219 'raw_id': 'a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
220 220 'refs': {'bookmarks': [], 'branches': [u'default'], 'tags': [u'tip']},
221 221 'reviewers': [],
222 222 'revision': 9L,
223 223 'short_id': 'a815cc738b96',
224 224 'url': u'http://rc.local:8080/hg-repo/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf'}],
225 225 'issues': {}},
226 226 'repo': {'extra_fields': '',
227 227 'permalink_url': u'http://rc.local:8080/_7',
228 228 'repo_id': 7,
229 229 'repo_name': u'hg-repo',
230 230 'repo_type': u'hg',
231 231 'url': u'http://rc.local:8080/hg-repo'},
232 232 'server_url': u'http://rc.local:8080',
233 233 'utc_timestamp': datetime.datetime(2017, 11, 30, 13, 0, 1, 569276)
234 234
235 235 """
236 236 max_retries = 3
237 237 retries = Retry(
238 238 total=max_retries,
239 239 backoff_factor=0.15,
240 240 status_forcelist=[500, 502, 503, 504])
241 241 call_headers = {
242 242 'User-Agent': 'RhodeCode-webhook-caller/{}'.format(
243 243 rhodecode.__version__)
244 244 } # updated below with custom ones, allows override
245 245
246 246 auth = get_auth(settings)
247 247 token = get_web_token(settings)
248 248
249 249 for url, headers, data in url_calls:
250 250 req_session = requests.Session()
251 251 req_session.mount( # retry max N times
252 252 'http://', requests.adapters.HTTPAdapter(max_retries=retries))
253 253
254 254 method = settings.get('method_type') or 'post'
255 255 call_method = getattr(req_session, method)
256 256
257 257 headers = headers or {}
258 258 call_headers.update(headers)
259 259
260 260 log.debug('calling Webhook with method: %s, and auth:%s',
261 261 call_method, auth)
262 262 if settings.get('log_data'):
263 263 log.debug('calling webhook with data: %s', data)
264 264 resp = call_method(url, json={
265 265 'token': token,
266 266 'event': data
267 }, headers=call_headers, auth=auth)
267 }, headers=call_headers, auth=auth, timeout=60)
268 268 log.debug('Got Webhook response: %s', resp)
269 269
270 270 try:
271 271 resp.raise_for_status() # raise exception on a failed request
272 272 except Exception:
273 273 log.error(resp.text)
274 274 raise
General Comments 0
You need to be logged in to leave comments. Login now