##// 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 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2016-2018 RhodeCode GmbH
3 # Copyright (C) 2016-2018 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 import time
21 import time
22 import collections
22 import collections
23 import datetime
23 import datetime
24 import formencode
24 import formencode
25 import formencode.htmlfill
25 import formencode.htmlfill
26 import logging
26 import logging
27 import urlparse
27 import urlparse
28 import requests
28 import requests
29
29
30 from pyramid.httpexceptions import HTTPFound
30 from pyramid.httpexceptions import HTTPFound
31 from pyramid.view import view_config
31 from pyramid.view import view_config
32
32
33 from rhodecode.apps._base import BaseAppView
33 from rhodecode.apps._base import BaseAppView
34 from rhodecode.authentication.base import authenticate, HTTP_TYPE
34 from rhodecode.authentication.base import authenticate, HTTP_TYPE
35 from rhodecode.events import UserRegistered, trigger
35 from rhodecode.events import UserRegistered, trigger
36 from rhodecode.lib import helpers as h
36 from rhodecode.lib import helpers as h
37 from rhodecode.lib import audit_logger
37 from rhodecode.lib import audit_logger
38 from rhodecode.lib.auth import (
38 from rhodecode.lib.auth import (
39 AuthUser, HasPermissionAnyDecorator, CSRFRequired)
39 AuthUser, HasPermissionAnyDecorator, CSRFRequired)
40 from rhodecode.lib.base import get_ip_addr
40 from rhodecode.lib.base import get_ip_addr
41 from rhodecode.lib.exceptions import UserCreationError
41 from rhodecode.lib.exceptions import UserCreationError
42 from rhodecode.lib.utils2 import safe_str
42 from rhodecode.lib.utils2 import safe_str
43 from rhodecode.model.db import User, UserApiKeys
43 from rhodecode.model.db import User, UserApiKeys
44 from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm
44 from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm
45 from rhodecode.model.meta import Session
45 from rhodecode.model.meta import Session
46 from rhodecode.model.auth_token import AuthTokenModel
46 from rhodecode.model.auth_token import AuthTokenModel
47 from rhodecode.model.settings import SettingsModel
47 from rhodecode.model.settings import SettingsModel
48 from rhodecode.model.user import UserModel
48 from rhodecode.model.user import UserModel
49 from rhodecode.translation import _
49 from rhodecode.translation import _
50
50
51
51
52 log = logging.getLogger(__name__)
52 log = logging.getLogger(__name__)
53
53
54 CaptchaData = collections.namedtuple(
54 CaptchaData = collections.namedtuple(
55 'CaptchaData', 'active, private_key, public_key')
55 'CaptchaData', 'active, private_key, public_key')
56
56
57
57
58 def _store_user_in_session(session, username, remember=False):
58 def _store_user_in_session(session, username, remember=False):
59 user = User.get_by_username(username, case_insensitive=True)
59 user = User.get_by_username(username, case_insensitive=True)
60 auth_user = AuthUser(user.user_id)
60 auth_user = AuthUser(user.user_id)
61 auth_user.set_authenticated()
61 auth_user.set_authenticated()
62 cs = auth_user.get_cookie_store()
62 cs = auth_user.get_cookie_store()
63 session['rhodecode_user'] = cs
63 session['rhodecode_user'] = cs
64 user.update_lastlogin()
64 user.update_lastlogin()
65 Session().commit()
65 Session().commit()
66
66
67 # If they want to be remembered, update the cookie
67 # If they want to be remembered, update the cookie
68 if remember:
68 if remember:
69 _year = (datetime.datetime.now() +
69 _year = (datetime.datetime.now() +
70 datetime.timedelta(seconds=60 * 60 * 24 * 365))
70 datetime.timedelta(seconds=60 * 60 * 24 * 365))
71 session._set_cookie_expires(_year)
71 session._set_cookie_expires(_year)
72
72
73 session.save()
73 session.save()
74
74
75 safe_cs = cs.copy()
75 safe_cs = cs.copy()
76 safe_cs['password'] = '****'
76 safe_cs['password'] = '****'
77 log.info('user %s is now authenticated and stored in '
77 log.info('user %s is now authenticated and stored in '
78 'session, session attrs %s', username, safe_cs)
78 'session, session attrs %s', username, safe_cs)
79
79
80 # dumps session attrs back to cookie
80 # dumps session attrs back to cookie
81 session._update_cookie_out()
81 session._update_cookie_out()
82 # we set new cookie
82 # we set new cookie
83 headers = None
83 headers = None
84 if session.request['set_cookie']:
84 if session.request['set_cookie']:
85 # send set-cookie headers back to response to update cookie
85 # send set-cookie headers back to response to update cookie
86 headers = [('Set-Cookie', session.request['cookie_out'])]
86 headers = [('Set-Cookie', session.request['cookie_out'])]
87 return headers
87 return headers
88
88
89
89
90 def get_came_from(request):
90 def get_came_from(request):
91 came_from = safe_str(request.GET.get('came_from', ''))
91 came_from = safe_str(request.GET.get('came_from', ''))
92 parsed = urlparse.urlparse(came_from)
92 parsed = urlparse.urlparse(came_from)
93 allowed_schemes = ['http', 'https']
93 allowed_schemes = ['http', 'https']
94 default_came_from = h.route_path('home')
94 default_came_from = h.route_path('home')
95 if parsed.scheme and parsed.scheme not in allowed_schemes:
95 if parsed.scheme and parsed.scheme not in allowed_schemes:
96 log.error('Suspicious URL scheme detected %s for url %s' %
96 log.error('Suspicious URL scheme detected %s for url %s' %
97 (parsed.scheme, parsed))
97 (parsed.scheme, parsed))
98 came_from = default_came_from
98 came_from = default_came_from
99 elif parsed.netloc and request.host != parsed.netloc:
99 elif parsed.netloc and request.host != parsed.netloc:
100 log.error('Suspicious NETLOC detected %s for url %s server url '
100 log.error('Suspicious NETLOC detected %s for url %s server url '
101 'is: %s' % (parsed.netloc, parsed, request.host))
101 'is: %s' % (parsed.netloc, parsed, request.host))
102 came_from = default_came_from
102 came_from = default_came_from
103 elif any(bad_str in parsed.path for bad_str in ('\r', '\n')):
103 elif any(bad_str in parsed.path for bad_str in ('\r', '\n')):
104 log.error('Header injection detected `%s` for url %s server url ' %
104 log.error('Header injection detected `%s` for url %s server url ' %
105 (parsed.path, parsed))
105 (parsed.path, parsed))
106 came_from = default_came_from
106 came_from = default_came_from
107
107
108 return came_from or default_came_from
108 return came_from or default_came_from
109
109
110
110
111 class LoginView(BaseAppView):
111 class LoginView(BaseAppView):
112
112
113 def load_default_context(self):
113 def load_default_context(self):
114 c = self._get_local_tmpl_context()
114 c = self._get_local_tmpl_context()
115 c.came_from = get_came_from(self.request)
115 c.came_from = get_came_from(self.request)
116
116
117 return c
117 return c
118
118
119 def _get_captcha_data(self):
119 def _get_captcha_data(self):
120 settings = SettingsModel().get_all_settings()
120 settings = SettingsModel().get_all_settings()
121 private_key = settings.get('rhodecode_captcha_private_key')
121 private_key = settings.get('rhodecode_captcha_private_key')
122 public_key = settings.get('rhodecode_captcha_public_key')
122 public_key = settings.get('rhodecode_captcha_public_key')
123 active = bool(private_key)
123 active = bool(private_key)
124 return CaptchaData(
124 return CaptchaData(
125 active=active, private_key=private_key, public_key=public_key)
125 active=active, private_key=private_key, public_key=public_key)
126
126
127 def validate_captcha(self, private_key):
127 def validate_captcha(self, private_key):
128
128
129 captcha_rs = self.request.POST.get('g-recaptcha-response')
129 captcha_rs = self.request.POST.get('g-recaptcha-response')
130 url = "https://www.google.com/recaptcha/api/siteverify"
130 url = "https://www.google.com/recaptcha/api/siteverify"
131 params = {
131 params = {
132 'secret': private_key,
132 'secret': private_key,
133 'response': captcha_rs,
133 'response': captcha_rs,
134 'remoteip': get_ip_addr(self.request.environ)
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 verify_rs = verify_rs.json()
137 verify_rs = verify_rs.json()
138 captcha_status = verify_rs.get('success', False)
138 captcha_status = verify_rs.get('success', False)
139 captcha_errors = verify_rs.get('error-codes', [])
139 captcha_errors = verify_rs.get('error-codes', [])
140 if not isinstance(captcha_errors, list):
140 if not isinstance(captcha_errors, list):
141 captcha_errors = [captcha_errors]
141 captcha_errors = [captcha_errors]
142 captcha_errors = ', '.join(captcha_errors)
142 captcha_errors = ', '.join(captcha_errors)
143 captcha_message = ''
143 captcha_message = ''
144 if captcha_status is False:
144 if captcha_status is False:
145 captcha_message = "Bad captcha. Errors: {}".format(
145 captcha_message = "Bad captcha. Errors: {}".format(
146 captcha_errors)
146 captcha_errors)
147
147
148 return captcha_status, captcha_message
148 return captcha_status, captcha_message
149
149
150 @view_config(
150 @view_config(
151 route_name='login', request_method='GET',
151 route_name='login', request_method='GET',
152 renderer='rhodecode:templates/login.mako')
152 renderer='rhodecode:templates/login.mako')
153 def login(self):
153 def login(self):
154 c = self.load_default_context()
154 c = self.load_default_context()
155 auth_user = self._rhodecode_user
155 auth_user = self._rhodecode_user
156
156
157 # redirect if already logged in
157 # redirect if already logged in
158 if (auth_user.is_authenticated and
158 if (auth_user.is_authenticated and
159 not auth_user.is_default and auth_user.ip_allowed):
159 not auth_user.is_default and auth_user.ip_allowed):
160 raise HTTPFound(c.came_from)
160 raise HTTPFound(c.came_from)
161
161
162 # check if we use headers plugin, and try to login using it.
162 # check if we use headers plugin, and try to login using it.
163 try:
163 try:
164 log.debug('Running PRE-AUTH for headers based authentication')
164 log.debug('Running PRE-AUTH for headers based authentication')
165 auth_info = authenticate(
165 auth_info = authenticate(
166 '', '', self.request.environ, HTTP_TYPE, skip_missing=True)
166 '', '', self.request.environ, HTTP_TYPE, skip_missing=True)
167 if auth_info:
167 if auth_info:
168 headers = _store_user_in_session(
168 headers = _store_user_in_session(
169 self.session, auth_info.get('username'))
169 self.session, auth_info.get('username'))
170 raise HTTPFound(c.came_from, headers=headers)
170 raise HTTPFound(c.came_from, headers=headers)
171 except UserCreationError as e:
171 except UserCreationError as e:
172 log.error(e)
172 log.error(e)
173 h.flash(e, category='error')
173 h.flash(e, category='error')
174
174
175 return self._get_template_context(c)
175 return self._get_template_context(c)
176
176
177 @view_config(
177 @view_config(
178 route_name='login', request_method='POST',
178 route_name='login', request_method='POST',
179 renderer='rhodecode:templates/login.mako')
179 renderer='rhodecode:templates/login.mako')
180 def login_post(self):
180 def login_post(self):
181 c = self.load_default_context()
181 c = self.load_default_context()
182
182
183 login_form = LoginForm(self.request.translate)()
183 login_form = LoginForm(self.request.translate)()
184
184
185 try:
185 try:
186 self.session.invalidate()
186 self.session.invalidate()
187 form_result = login_form.to_python(self.request.POST)
187 form_result = login_form.to_python(self.request.POST)
188 # form checks for username/password, now we're authenticated
188 # form checks for username/password, now we're authenticated
189 headers = _store_user_in_session(
189 headers = _store_user_in_session(
190 self.session,
190 self.session,
191 username=form_result['username'],
191 username=form_result['username'],
192 remember=form_result['remember'])
192 remember=form_result['remember'])
193 log.debug('Redirecting to "%s" after login.', c.came_from)
193 log.debug('Redirecting to "%s" after login.', c.came_from)
194
194
195 audit_user = audit_logger.UserWrap(
195 audit_user = audit_logger.UserWrap(
196 username=self.request.POST.get('username'),
196 username=self.request.POST.get('username'),
197 ip_addr=self.request.remote_addr)
197 ip_addr=self.request.remote_addr)
198 action_data = {'user_agent': self.request.user_agent}
198 action_data = {'user_agent': self.request.user_agent}
199 audit_logger.store_web(
199 audit_logger.store_web(
200 'user.login.success', action_data=action_data,
200 'user.login.success', action_data=action_data,
201 user=audit_user, commit=True)
201 user=audit_user, commit=True)
202
202
203 raise HTTPFound(c.came_from, headers=headers)
203 raise HTTPFound(c.came_from, headers=headers)
204 except formencode.Invalid as errors:
204 except formencode.Invalid as errors:
205 defaults = errors.value
205 defaults = errors.value
206 # remove password from filling in form again
206 # remove password from filling in form again
207 defaults.pop('password', None)
207 defaults.pop('password', None)
208 render_ctx = {
208 render_ctx = {
209 'errors': errors.error_dict,
209 'errors': errors.error_dict,
210 'defaults': defaults,
210 'defaults': defaults,
211 }
211 }
212
212
213 audit_user = audit_logger.UserWrap(
213 audit_user = audit_logger.UserWrap(
214 username=self.request.POST.get('username'),
214 username=self.request.POST.get('username'),
215 ip_addr=self.request.remote_addr)
215 ip_addr=self.request.remote_addr)
216 action_data = {'user_agent': self.request.user_agent}
216 action_data = {'user_agent': self.request.user_agent}
217 audit_logger.store_web(
217 audit_logger.store_web(
218 'user.login.failure', action_data=action_data,
218 'user.login.failure', action_data=action_data,
219 user=audit_user, commit=True)
219 user=audit_user, commit=True)
220 return self._get_template_context(c, **render_ctx)
220 return self._get_template_context(c, **render_ctx)
221
221
222 except UserCreationError as e:
222 except UserCreationError as e:
223 # headers auth or other auth functions that create users on
223 # headers auth or other auth functions that create users on
224 # the fly can throw this exception signaling that there's issue
224 # the fly can throw this exception signaling that there's issue
225 # with user creation, explanation should be provided in
225 # with user creation, explanation should be provided in
226 # Exception itself
226 # Exception itself
227 h.flash(e, category='error')
227 h.flash(e, category='error')
228 return self._get_template_context(c)
228 return self._get_template_context(c)
229
229
230 @CSRFRequired()
230 @CSRFRequired()
231 @view_config(route_name='logout', request_method='POST')
231 @view_config(route_name='logout', request_method='POST')
232 def logout(self):
232 def logout(self):
233 auth_user = self._rhodecode_user
233 auth_user = self._rhodecode_user
234 log.info('Deleting session for user: `%s`', auth_user)
234 log.info('Deleting session for user: `%s`', auth_user)
235
235
236 action_data = {'user_agent': self.request.user_agent}
236 action_data = {'user_agent': self.request.user_agent}
237 audit_logger.store_web(
237 audit_logger.store_web(
238 'user.logout', action_data=action_data,
238 'user.logout', action_data=action_data,
239 user=auth_user, commit=True)
239 user=auth_user, commit=True)
240 self.session.delete()
240 self.session.delete()
241 return HTTPFound(h.route_path('home'))
241 return HTTPFound(h.route_path('home'))
242
242
243 @HasPermissionAnyDecorator(
243 @HasPermissionAnyDecorator(
244 'hg.admin', 'hg.register.auto_activate', 'hg.register.manual_activate')
244 'hg.admin', 'hg.register.auto_activate', 'hg.register.manual_activate')
245 @view_config(
245 @view_config(
246 route_name='register', request_method='GET',
246 route_name='register', request_method='GET',
247 renderer='rhodecode:templates/register.mako',)
247 renderer='rhodecode:templates/register.mako',)
248 def register(self, defaults=None, errors=None):
248 def register(self, defaults=None, errors=None):
249 c = self.load_default_context()
249 c = self.load_default_context()
250 defaults = defaults or {}
250 defaults = defaults or {}
251 errors = errors or {}
251 errors = errors or {}
252
252
253 settings = SettingsModel().get_all_settings()
253 settings = SettingsModel().get_all_settings()
254 register_message = settings.get('rhodecode_register_message') or ''
254 register_message = settings.get('rhodecode_register_message') or ''
255 captcha = self._get_captcha_data()
255 captcha = self._get_captcha_data()
256 auto_active = 'hg.register.auto_activate' in User.get_default_user()\
256 auto_active = 'hg.register.auto_activate' in User.get_default_user()\
257 .AuthUser().permissions['global']
257 .AuthUser().permissions['global']
258
258
259 render_ctx = self._get_template_context(c)
259 render_ctx = self._get_template_context(c)
260 render_ctx.update({
260 render_ctx.update({
261 'defaults': defaults,
261 'defaults': defaults,
262 'errors': errors,
262 'errors': errors,
263 'auto_active': auto_active,
263 'auto_active': auto_active,
264 'captcha_active': captcha.active,
264 'captcha_active': captcha.active,
265 'captcha_public_key': captcha.public_key,
265 'captcha_public_key': captcha.public_key,
266 'register_message': register_message,
266 'register_message': register_message,
267 })
267 })
268 return render_ctx
268 return render_ctx
269
269
270 @HasPermissionAnyDecorator(
270 @HasPermissionAnyDecorator(
271 'hg.admin', 'hg.register.auto_activate', 'hg.register.manual_activate')
271 'hg.admin', 'hg.register.auto_activate', 'hg.register.manual_activate')
272 @view_config(
272 @view_config(
273 route_name='register', request_method='POST',
273 route_name='register', request_method='POST',
274 renderer='rhodecode:templates/register.mako')
274 renderer='rhodecode:templates/register.mako')
275 def register_post(self):
275 def register_post(self):
276 self.load_default_context()
276 self.load_default_context()
277 captcha = self._get_captcha_data()
277 captcha = self._get_captcha_data()
278 auto_active = 'hg.register.auto_activate' in User.get_default_user()\
278 auto_active = 'hg.register.auto_activate' in User.get_default_user()\
279 .AuthUser().permissions['global']
279 .AuthUser().permissions['global']
280
280
281 register_form = RegisterForm(self.request.translate)()
281 register_form = RegisterForm(self.request.translate)()
282 try:
282 try:
283
283
284 form_result = register_form.to_python(self.request.POST)
284 form_result = register_form.to_python(self.request.POST)
285 form_result['active'] = auto_active
285 form_result['active'] = auto_active
286
286
287 if captcha.active:
287 if captcha.active:
288 captcha_status, captcha_message = self.validate_captcha(
288 captcha_status, captcha_message = self.validate_captcha(
289 captcha.private_key)
289 captcha.private_key)
290
290
291 if not captcha_status:
291 if not captcha_status:
292 _value = form_result
292 _value = form_result
293 _msg = _('Bad captcha')
293 _msg = _('Bad captcha')
294 error_dict = {'recaptcha_field': captcha_message}
294 error_dict = {'recaptcha_field': captcha_message}
295 raise formencode.Invalid(
295 raise formencode.Invalid(
296 _msg, _value, None, error_dict=error_dict)
296 _msg, _value, None, error_dict=error_dict)
297
297
298 new_user = UserModel().create_registration(form_result)
298 new_user = UserModel().create_registration(form_result)
299
299
300 action_data = {'data': new_user.get_api_data(),
300 action_data = {'data': new_user.get_api_data(),
301 'user_agent': self.request.user_agent}
301 'user_agent': self.request.user_agent}
302
302
303 audit_user = audit_logger.UserWrap(
303 audit_user = audit_logger.UserWrap(
304 username=new_user.username,
304 username=new_user.username,
305 user_id=new_user.user_id,
305 user_id=new_user.user_id,
306 ip_addr=self.request.remote_addr)
306 ip_addr=self.request.remote_addr)
307
307
308 audit_logger.store_web(
308 audit_logger.store_web(
309 'user.register', action_data=action_data,
309 'user.register', action_data=action_data,
310 user=audit_user)
310 user=audit_user)
311
311
312 event = UserRegistered(user=new_user, session=self.session)
312 event = UserRegistered(user=new_user, session=self.session)
313 trigger(event)
313 trigger(event)
314 h.flash(
314 h.flash(
315 _('You have successfully registered with RhodeCode'),
315 _('You have successfully registered with RhodeCode'),
316 category='success')
316 category='success')
317 Session().commit()
317 Session().commit()
318
318
319 redirect_ro = self.request.route_path('login')
319 redirect_ro = self.request.route_path('login')
320 raise HTTPFound(redirect_ro)
320 raise HTTPFound(redirect_ro)
321
321
322 except formencode.Invalid as errors:
322 except formencode.Invalid as errors:
323 errors.value.pop('password', None)
323 errors.value.pop('password', None)
324 errors.value.pop('password_confirmation', None)
324 errors.value.pop('password_confirmation', None)
325 return self.register(
325 return self.register(
326 defaults=errors.value, errors=errors.error_dict)
326 defaults=errors.value, errors=errors.error_dict)
327
327
328 except UserCreationError as e:
328 except UserCreationError as e:
329 # container auth or other auth functions that create users on
329 # container auth or other auth functions that create users on
330 # the fly can throw this exception signaling that there's issue
330 # the fly can throw this exception signaling that there's issue
331 # with user creation, explanation should be provided in
331 # with user creation, explanation should be provided in
332 # Exception itself
332 # Exception itself
333 h.flash(e, category='error')
333 h.flash(e, category='error')
334 return self.register()
334 return self.register()
335
335
336 @view_config(
336 @view_config(
337 route_name='reset_password', request_method=('GET', 'POST'),
337 route_name='reset_password', request_method=('GET', 'POST'),
338 renderer='rhodecode:templates/password_reset.mako')
338 renderer='rhodecode:templates/password_reset.mako')
339 def password_reset(self):
339 def password_reset(self):
340 c = self.load_default_context()
340 c = self.load_default_context()
341 captcha = self._get_captcha_data()
341 captcha = self._get_captcha_data()
342
342
343 template_context = {
343 template_context = {
344 'captcha_active': captcha.active,
344 'captcha_active': captcha.active,
345 'captcha_public_key': captcha.public_key,
345 'captcha_public_key': captcha.public_key,
346 'defaults': {},
346 'defaults': {},
347 'errors': {},
347 'errors': {},
348 }
348 }
349
349
350 # always send implicit message to prevent from discovery of
350 # always send implicit message to prevent from discovery of
351 # matching emails
351 # matching emails
352 msg = _('If such email exists, a password reset link was sent to it.')
352 msg = _('If such email exists, a password reset link was sent to it.')
353
353
354 if self.request.POST:
354 if self.request.POST:
355 if h.HasPermissionAny('hg.password_reset.disabled')():
355 if h.HasPermissionAny('hg.password_reset.disabled')():
356 _email = self.request.POST.get('email', '')
356 _email = self.request.POST.get('email', '')
357 log.error('Failed attempt to reset password for `%s`.', _email)
357 log.error('Failed attempt to reset password for `%s`.', _email)
358 h.flash(_('Password reset has been disabled.'),
358 h.flash(_('Password reset has been disabled.'),
359 category='error')
359 category='error')
360 return HTTPFound(self.request.route_path('reset_password'))
360 return HTTPFound(self.request.route_path('reset_password'))
361
361
362 password_reset_form = PasswordResetForm(self.request.translate)()
362 password_reset_form = PasswordResetForm(self.request.translate)()
363 try:
363 try:
364 form_result = password_reset_form.to_python(
364 form_result = password_reset_form.to_python(
365 self.request.POST)
365 self.request.POST)
366 user_email = form_result['email']
366 user_email = form_result['email']
367
367
368 if captcha.active:
368 if captcha.active:
369 captcha_status, captcha_message = self.validate_captcha(
369 captcha_status, captcha_message = self.validate_captcha(
370 captcha.private_key)
370 captcha.private_key)
371
371
372 if not captcha_status:
372 if not captcha_status:
373 _value = form_result
373 _value = form_result
374 _msg = _('Bad captcha')
374 _msg = _('Bad captcha')
375 error_dict = {'recaptcha_field': captcha_message}
375 error_dict = {'recaptcha_field': captcha_message}
376 raise formencode.Invalid(
376 raise formencode.Invalid(
377 _msg, _value, None, error_dict=error_dict)
377 _msg, _value, None, error_dict=error_dict)
378
378
379 # Generate reset URL and send mail.
379 # Generate reset URL and send mail.
380 user = User.get_by_email(user_email)
380 user = User.get_by_email(user_email)
381
381
382 # generate password reset token that expires in 10minutes
382 # generate password reset token that expires in 10minutes
383 desc = 'Generated token for password reset from {}'.format(
383 desc = 'Generated token for password reset from {}'.format(
384 datetime.datetime.now().isoformat())
384 datetime.datetime.now().isoformat())
385 reset_token = AuthTokenModel().create(
385 reset_token = AuthTokenModel().create(
386 user, lifetime=10,
386 user, lifetime=10,
387 description=desc,
387 description=desc,
388 role=UserApiKeys.ROLE_PASSWORD_RESET)
388 role=UserApiKeys.ROLE_PASSWORD_RESET)
389 Session().commit()
389 Session().commit()
390
390
391 log.debug('Successfully created password recovery token')
391 log.debug('Successfully created password recovery token')
392 password_reset_url = self.request.route_url(
392 password_reset_url = self.request.route_url(
393 'reset_password_confirmation',
393 'reset_password_confirmation',
394 _query={'key': reset_token.api_key})
394 _query={'key': reset_token.api_key})
395 UserModel().reset_password_link(
395 UserModel().reset_password_link(
396 form_result, password_reset_url)
396 form_result, password_reset_url)
397 # Display success message and redirect.
397 # Display success message and redirect.
398 h.flash(msg, category='success')
398 h.flash(msg, category='success')
399
399
400 action_data = {'email': user_email,
400 action_data = {'email': user_email,
401 'user_agent': self.request.user_agent}
401 'user_agent': self.request.user_agent}
402 audit_logger.store_web(
402 audit_logger.store_web(
403 'user.password.reset_request', action_data=action_data,
403 'user.password.reset_request', action_data=action_data,
404 user=self._rhodecode_user, commit=True)
404 user=self._rhodecode_user, commit=True)
405 return HTTPFound(self.request.route_path('reset_password'))
405 return HTTPFound(self.request.route_path('reset_password'))
406
406
407 except formencode.Invalid as errors:
407 except formencode.Invalid as errors:
408 template_context.update({
408 template_context.update({
409 'defaults': errors.value,
409 'defaults': errors.value,
410 'errors': errors.error_dict,
410 'errors': errors.error_dict,
411 })
411 })
412 if not self.request.POST.get('email'):
412 if not self.request.POST.get('email'):
413 # case of empty email, we want to report that
413 # case of empty email, we want to report that
414 return self._get_template_context(c, **template_context)
414 return self._get_template_context(c, **template_context)
415
415
416 if 'recaptcha_field' in errors.error_dict:
416 if 'recaptcha_field' in errors.error_dict:
417 # case of failed captcha
417 # case of failed captcha
418 return self._get_template_context(c, **template_context)
418 return self._get_template_context(c, **template_context)
419
419
420 log.debug('faking response on invalid password reset')
420 log.debug('faking response on invalid password reset')
421 # make this take 2s, to prevent brute forcing.
421 # make this take 2s, to prevent brute forcing.
422 time.sleep(2)
422 time.sleep(2)
423 h.flash(msg, category='success')
423 h.flash(msg, category='success')
424 return HTTPFound(self.request.route_path('reset_password'))
424 return HTTPFound(self.request.route_path('reset_password'))
425
425
426 return self._get_template_context(c, **template_context)
426 return self._get_template_context(c, **template_context)
427
427
428 @view_config(route_name='reset_password_confirmation',
428 @view_config(route_name='reset_password_confirmation',
429 request_method='GET')
429 request_method='GET')
430 def password_reset_confirmation(self):
430 def password_reset_confirmation(self):
431 self.load_default_context()
431 self.load_default_context()
432 if self.request.GET and self.request.GET.get('key'):
432 if self.request.GET and self.request.GET.get('key'):
433 # make this take 2s, to prevent brute forcing.
433 # make this take 2s, to prevent brute forcing.
434 time.sleep(2)
434 time.sleep(2)
435
435
436 token = AuthTokenModel().get_auth_token(
436 token = AuthTokenModel().get_auth_token(
437 self.request.GET.get('key'))
437 self.request.GET.get('key'))
438
438
439 # verify token is the correct role
439 # verify token is the correct role
440 if token is None or token.role != UserApiKeys.ROLE_PASSWORD_RESET:
440 if token is None or token.role != UserApiKeys.ROLE_PASSWORD_RESET:
441 log.debug('Got token with role:%s expected is %s',
441 log.debug('Got token with role:%s expected is %s',
442 getattr(token, 'role', 'EMPTY_TOKEN'),
442 getattr(token, 'role', 'EMPTY_TOKEN'),
443 UserApiKeys.ROLE_PASSWORD_RESET)
443 UserApiKeys.ROLE_PASSWORD_RESET)
444 h.flash(
444 h.flash(
445 _('Given reset token is invalid'), category='error')
445 _('Given reset token is invalid'), category='error')
446 return HTTPFound(self.request.route_path('reset_password'))
446 return HTTPFound(self.request.route_path('reset_password'))
447
447
448 try:
448 try:
449 owner = token.user
449 owner = token.user
450 data = {'email': owner.email, 'token': token.api_key}
450 data = {'email': owner.email, 'token': token.api_key}
451 UserModel().reset_password(data)
451 UserModel().reset_password(data)
452 h.flash(
452 h.flash(
453 _('Your password reset was successful, '
453 _('Your password reset was successful, '
454 'a new password has been sent to your email'),
454 'a new password has been sent to your email'),
455 category='success')
455 category='success')
456 except Exception as e:
456 except Exception as e:
457 log.error(e)
457 log.error(e)
458 return HTTPFound(self.request.route_path('reset_password'))
458 return HTTPFound(self.request.route_path('reset_password'))
459
459
460 return HTTPFound(self.request.route_path('login'))
460 return HTTPFound(self.request.route_path('login'))
@@ -1,251 +1,253 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2012-2018 RhodeCode GmbH
3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 from __future__ import unicode_literals
21 from __future__ import unicode_literals
22 import deform
22 import deform
23 import logging
23 import logging
24 import requests
24 import requests
25 import colander
25 import colander
26 import textwrap
26 import textwrap
27 from mako.template import Template
27 from mako.template import Template
28 from rhodecode import events
28 from rhodecode import events
29 from rhodecode.translation import _
29 from rhodecode.translation import _
30 from rhodecode.lib import helpers as h
30 from rhodecode.lib import helpers as h
31 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
31 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
32 from rhodecode.lib.colander_utils import strip_whitespace
32 from rhodecode.lib.colander_utils import strip_whitespace
33 from rhodecode.integrations.types.base import (
33 from rhodecode.integrations.types.base import (
34 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback)
34 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback)
35
35
36 log = logging.getLogger(__name__)
36 log = logging.getLogger(__name__)
37
37
38
38
39 class HipchatSettingsSchema(colander.Schema):
39 class HipchatSettingsSchema(colander.Schema):
40 color_choices = [
40 color_choices = [
41 ('yellow', _('Yellow')),
41 ('yellow', _('Yellow')),
42 ('red', _('Red')),
42 ('red', _('Red')),
43 ('green', _('Green')),
43 ('green', _('Green')),
44 ('purple', _('Purple')),
44 ('purple', _('Purple')),
45 ('gray', _('Gray')),
45 ('gray', _('Gray')),
46 ]
46 ]
47
47
48 server_url = colander.SchemaNode(
48 server_url = colander.SchemaNode(
49 colander.String(),
49 colander.String(),
50 title=_('Hipchat server URL'),
50 title=_('Hipchat server URL'),
51 description=_('Hipchat integration url.'),
51 description=_('Hipchat integration url.'),
52 default='',
52 default='',
53 preparer=strip_whitespace,
53 preparer=strip_whitespace,
54 validator=colander.url,
54 validator=colander.url,
55 widget=deform.widget.TextInputWidget(
55 widget=deform.widget.TextInputWidget(
56 placeholder='https://?.hipchat.com/v2/room/?/notification?auth_token=?',
56 placeholder='https://?.hipchat.com/v2/room/?/notification?auth_token=?',
57 ),
57 ),
58 )
58 )
59 notify = colander.SchemaNode(
59 notify = colander.SchemaNode(
60 colander.Bool(),
60 colander.Bool(),
61 title=_('Notify'),
61 title=_('Notify'),
62 description=_('Make a notification to the users in room.'),
62 description=_('Make a notification to the users in room.'),
63 missing=False,
63 missing=False,
64 default=False,
64 default=False,
65 )
65 )
66 color = colander.SchemaNode(
66 color = colander.SchemaNode(
67 colander.String(),
67 colander.String(),
68 title=_('Color'),
68 title=_('Color'),
69 description=_('Background color of message.'),
69 description=_('Background color of message.'),
70 missing='',
70 missing='',
71 validator=colander.OneOf([x[0] for x in color_choices]),
71 validator=colander.OneOf([x[0] for x in color_choices]),
72 widget=deform.widget.Select2Widget(
72 widget=deform.widget.Select2Widget(
73 values=color_choices,
73 values=color_choices,
74 ),
74 ),
75 )
75 )
76
76
77
77
78 repo_push_template = Template('''
78 repo_push_template = Template('''
79 <b>${data['actor']['username']}</b> pushed to repo <a href="${data['repo']['url']}">${data['repo']['repo_name']}</a>:
79 <b>${data['actor']['username']}</b> pushed to repo <a href="${data['repo']['url']}">${data['repo']['repo_name']}</a>:
80 <br>
80 <br>
81 <ul>
81 <ul>
82 %for branch, branch_commits in branches_commits.items():
82 %for branch, branch_commits in branches_commits.items():
83 <li>
83 <li>
84 % if branch:
84 % if branch:
85 <a href="${branch_commits['branch']['url']}">branch: ${branch_commits['branch']['name']}</a>
85 <a href="${branch_commits['branch']['url']}">branch: ${branch_commits['branch']['name']}</a>
86 % else:
86 % else:
87 to trunk
87 to trunk
88 % endif
88 % endif
89 <ul>
89 <ul>
90 % for commit in branch_commits['commits']:
90 % for commit in branch_commits['commits']:
91 <li><a href="${commit['url']}">${commit['short_id']}</a> - ${commit['message_html']}</li>
91 <li><a href="${commit['url']}">${commit['short_id']}</a> - ${commit['message_html']}</li>
92 % endfor
92 % endfor
93 </ul>
93 </ul>
94 </li>
94 </li>
95 %endfor
95 %endfor
96 ''')
96 ''')
97
97
98
98
99 class HipchatIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
99 class HipchatIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
100 key = 'hipchat'
100 key = 'hipchat'
101 display_name = _('Hipchat')
101 display_name = _('Hipchat')
102 description = _('Send events such as repo pushes and pull requests to '
102 description = _('Send events such as repo pushes and pull requests to '
103 'your hipchat channel.')
103 'your hipchat channel.')
104
104
105 @classmethod
105 @classmethod
106 def icon(cls):
106 def icon(cls):
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>'''
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 valid_events = [
109 valid_events = [
110 events.PullRequestCloseEvent,
110 events.PullRequestCloseEvent,
111 events.PullRequestMergeEvent,
111 events.PullRequestMergeEvent,
112 events.PullRequestUpdateEvent,
112 events.PullRequestUpdateEvent,
113 events.PullRequestCommentEvent,
113 events.PullRequestCommentEvent,
114 events.PullRequestReviewEvent,
114 events.PullRequestReviewEvent,
115 events.PullRequestCreateEvent,
115 events.PullRequestCreateEvent,
116 events.RepoPushEvent,
116 events.RepoPushEvent,
117 events.RepoCreateEvent,
117 events.RepoCreateEvent,
118 ]
118 ]
119
119
120 def send_event(self, event):
120 def send_event(self, event):
121 if event.__class__ not in self.valid_events:
121 if event.__class__ not in self.valid_events:
122 log.debug('event not valid: %r' % event)
122 log.debug('event not valid: %r' % event)
123 return
123 return
124
124
125 if event.name not in self.settings['events']:
125 if event.name not in self.settings['events']:
126 log.debug('event ignored: %r' % event)
126 log.debug('event ignored: %r' % event)
127 return
127 return
128
128
129 data = event.as_dict()
129 data = event.as_dict()
130
130
131 text = '<b>%s<b> caused a <b>%s</b> event' % (
131 text = '<b>%s<b> caused a <b>%s</b> event' % (
132 data['actor']['username'], event.name)
132 data['actor']['username'], event.name)
133
133
134 log.debug('handling hipchat event for %s' % event.name)
134 log.debug('handling hipchat event for %s' % event.name)
135
135
136 if isinstance(event, events.PullRequestCommentEvent):
136 if isinstance(event, events.PullRequestCommentEvent):
137 text = self.format_pull_request_comment_event(event, data)
137 text = self.format_pull_request_comment_event(event, data)
138 elif isinstance(event, events.PullRequestReviewEvent):
138 elif isinstance(event, events.PullRequestReviewEvent):
139 text = self.format_pull_request_review_event(event, data)
139 text = self.format_pull_request_review_event(event, data)
140 elif isinstance(event, events.PullRequestEvent):
140 elif isinstance(event, events.PullRequestEvent):
141 text = self.format_pull_request_event(event, data)
141 text = self.format_pull_request_event(event, data)
142 elif isinstance(event, events.RepoPushEvent):
142 elif isinstance(event, events.RepoPushEvent):
143 text = self.format_repo_push_event(data)
143 text = self.format_repo_push_event(data)
144 elif isinstance(event, events.RepoCreateEvent):
144 elif isinstance(event, events.RepoCreateEvent):
145 text = self.format_repo_create_event(data)
145 text = self.format_repo_create_event(data)
146 else:
146 else:
147 log.error('unhandled event type: %r' % event)
147 log.error('unhandled event type: %r' % event)
148
148
149 run_task(post_text_to_hipchat, self.settings, text)
149 run_task(post_text_to_hipchat, self.settings, text)
150
150
151 def settings_schema(self):
151 def settings_schema(self):
152 schema = HipchatSettingsSchema()
152 schema = HipchatSettingsSchema()
153 schema.add(colander.SchemaNode(
153 schema.add(colander.SchemaNode(
154 colander.Set(),
154 colander.Set(),
155 widget=deform.widget.CheckboxChoiceWidget(
155 widget=deform.widget.CheckboxChoiceWidget(
156 values=sorted(
156 values=sorted(
157 [(e.name, e.display_name) for e in self.valid_events]
157 [(e.name, e.display_name) for e in self.valid_events]
158 )
158 )
159 ),
159 ),
160 description="Events activated for this integration",
160 description="Events activated for this integration",
161 name='events'
161 name='events'
162 ))
162 ))
163
163
164 return schema
164 return schema
165
165
166 def format_pull_request_comment_event(self, event, data):
166 def format_pull_request_comment_event(self, event, data):
167 comment_text = data['comment']['text']
167 comment_text = data['comment']['text']
168 if len(comment_text) > 200:
168 if len(comment_text) > 200:
169 comment_text = '{comment_text}<a href="{comment_url}">...<a/>'.format(
169 comment_text = '{comment_text}<a href="{comment_url}">...<a/>'.format(
170 comment_text=h.html_escape(comment_text[:200]),
170 comment_text=h.html_escape(comment_text[:200]),
171 comment_url=data['comment']['url'],
171 comment_url=data['comment']['url'],
172 )
172 )
173
173
174 comment_status = ''
174 comment_status = ''
175 if data['comment']['status']:
175 if data['comment']['status']:
176 comment_status = '[{}]: '.format(data['comment']['status'])
176 comment_status = '[{}]: '.format(data['comment']['status'])
177
177
178 return (textwrap.dedent(
178 return (textwrap.dedent(
179 '''
179 '''
180 {user} commented on pull request <a href="{pr_url}">{number}</a> - {pr_title}:
180 {user} commented on pull request <a href="{pr_url}">{number}</a> - {pr_title}:
181 >>> {comment_status}{comment_text}
181 >>> {comment_status}{comment_text}
182 ''').format(
182 ''').format(
183 comment_status=comment_status,
183 comment_status=comment_status,
184 user=data['actor']['username'],
184 user=data['actor']['username'],
185 number=data['pullrequest']['pull_request_id'],
185 number=data['pullrequest']['pull_request_id'],
186 pr_url=data['pullrequest']['url'],
186 pr_url=data['pullrequest']['url'],
187 pr_status=data['pullrequest']['status'],
187 pr_status=data['pullrequest']['status'],
188 pr_title=h.html_escape(data['pullrequest']['title']),
188 pr_title=h.html_escape(data['pullrequest']['title']),
189 comment_text=h.html_escape(comment_text)
189 comment_text=h.html_escape(comment_text)
190 )
190 )
191 )
191 )
192
192
193 def format_pull_request_review_event(self, event, data):
193 def format_pull_request_review_event(self, event, data):
194 return (textwrap.dedent(
194 return (textwrap.dedent(
195 '''
195 '''
196 Status changed to {pr_status} for pull request <a href="{pr_url}">#{number}</a> - {pr_title}
196 Status changed to {pr_status} for pull request <a href="{pr_url}">#{number}</a> - {pr_title}
197 ''').format(
197 ''').format(
198 user=data['actor']['username'],
198 user=data['actor']['username'],
199 number=data['pullrequest']['pull_request_id'],
199 number=data['pullrequest']['pull_request_id'],
200 pr_url=data['pullrequest']['url'],
200 pr_url=data['pullrequest']['url'],
201 pr_status=data['pullrequest']['status'],
201 pr_status=data['pullrequest']['status'],
202 pr_title=h.html_escape(data['pullrequest']['title']),
202 pr_title=h.html_escape(data['pullrequest']['title']),
203 )
203 )
204 )
204 )
205
205
206 def format_pull_request_event(self, event, data):
206 def format_pull_request_event(self, event, data):
207 action = {
207 action = {
208 events.PullRequestCloseEvent: 'closed',
208 events.PullRequestCloseEvent: 'closed',
209 events.PullRequestMergeEvent: 'merged',
209 events.PullRequestMergeEvent: 'merged',
210 events.PullRequestUpdateEvent: 'updated',
210 events.PullRequestUpdateEvent: 'updated',
211 events.PullRequestCreateEvent: 'created',
211 events.PullRequestCreateEvent: 'created',
212 }.get(event.__class__, str(event.__class__))
212 }.get(event.__class__, str(event.__class__))
213
213
214 return ('Pull request <a href="{url}">#{number}</a> - {title} '
214 return ('Pull request <a href="{url}">#{number}</a> - {title} '
215 '{action} by <b>{user}</b>').format(
215 '{action} by <b>{user}</b>').format(
216 user=data['actor']['username'],
216 user=data['actor']['username'],
217 number=data['pullrequest']['pull_request_id'],
217 number=data['pullrequest']['pull_request_id'],
218 url=data['pullrequest']['url'],
218 url=data['pullrequest']['url'],
219 title=h.html_escape(data['pullrequest']['title']),
219 title=h.html_escape(data['pullrequest']['title']),
220 action=action
220 action=action
221 )
221 )
222
222
223 def format_repo_push_event(self, data):
223 def format_repo_push_event(self, data):
224 branches_commits = self.aggregate_branch_data(
224 branches_commits = self.aggregate_branch_data(
225 data['push']['branches'], data['push']['commits'])
225 data['push']['branches'], data['push']['commits'])
226
226
227 result = render_with_traceback(
227 result = render_with_traceback(
228 repo_push_template,
228 repo_push_template,
229 data=data,
229 data=data,
230 branches_commits=branches_commits,
230 branches_commits=branches_commits,
231 )
231 )
232 return result
232 return result
233
233
234 def format_repo_create_event(self, data):
234 def format_repo_create_event(self, data):
235 return '<a href="{}">{}</a> ({}) repository created by <b>{}</b>'.format(
235 return '<a href="{}">{}</a> ({}) repository created by <b>{}</b>'.format(
236 data['repo']['url'],
236 data['repo']['url'],
237 h.html_escape(data['repo']['repo_name']),
237 h.html_escape(data['repo']['repo_name']),
238 data['repo']['repo_type'],
238 data['repo']['repo_type'],
239 data['actor']['username'],
239 data['actor']['username'],
240 )
240 )
241
241
242
242
243 @async_task(ignore_result=True, base=RequestContextTask)
243 @async_task(ignore_result=True, base=RequestContextTask)
244 def post_text_to_hipchat(settings, text):
244 def post_text_to_hipchat(settings, text):
245 log.debug('sending %s to hipchat %s' % (text, settings['server_url']))
245 log.debug('sending %s to hipchat %s' % (text, settings['server_url']))
246 resp = requests.post(settings['server_url'], json={
246 json_message = {
247 "message": text,
247 "message": text,
248 "color": settings.get('color', 'yellow'),
248 "color": settings.get('color', 'yellow'),
249 "notify": settings.get('notify', False),
249 "notify": settings.get('notify', False),
250 })
250 }
251
252 resp = requests.post(settings['server_url'], json=json_message, timeout=60)
251 resp.raise_for_status() # raise exception on a failed request
253 resp.raise_for_status() # raise exception on a failed request
@@ -1,350 +1,350 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2012-2018 RhodeCode GmbH
3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 from __future__ import unicode_literals
21 from __future__ import unicode_literals
22 import re
22 import re
23 import time
23 import time
24 import textwrap
24 import textwrap
25 import logging
25 import logging
26
26
27 import deform
27 import deform
28 import requests
28 import requests
29 import colander
29 import colander
30 from mako.template import Template
30 from mako.template import Template
31
31
32 from rhodecode import events
32 from rhodecode import events
33 from rhodecode.translation import _
33 from rhodecode.translation import _
34 from rhodecode.lib import helpers as h
34 from rhodecode.lib import helpers as h
35 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
35 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
36 from rhodecode.lib.colander_utils import strip_whitespace
36 from rhodecode.lib.colander_utils import strip_whitespace
37 from rhodecode.integrations.types.base import (
37 from rhodecode.integrations.types.base import (
38 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback)
38 IntegrationTypeBase, CommitParsingDataHandler, render_with_traceback)
39
39
40 log = logging.getLogger(__name__)
40 log = logging.getLogger(__name__)
41
41
42
42
43 class SlackSettingsSchema(colander.Schema):
43 class SlackSettingsSchema(colander.Schema):
44 service = colander.SchemaNode(
44 service = colander.SchemaNode(
45 colander.String(),
45 colander.String(),
46 title=_('Slack service URL'),
46 title=_('Slack service URL'),
47 description=h.literal(_(
47 description=h.literal(_(
48 'This can be setup at the '
48 'This can be setup at the '
49 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
49 '<a href="https://my.slack.com/services/new/incoming-webhook/">'
50 'slack app manager</a>')),
50 'slack app manager</a>')),
51 default='',
51 default='',
52 preparer=strip_whitespace,
52 preparer=strip_whitespace,
53 validator=colander.url,
53 validator=colander.url,
54 widget=deform.widget.TextInputWidget(
54 widget=deform.widget.TextInputWidget(
55 placeholder='https://hooks.slack.com/services/...',
55 placeholder='https://hooks.slack.com/services/...',
56 ),
56 ),
57 )
57 )
58 username = colander.SchemaNode(
58 username = colander.SchemaNode(
59 colander.String(),
59 colander.String(),
60 title=_('Username'),
60 title=_('Username'),
61 description=_('Username to show notifications coming from.'),
61 description=_('Username to show notifications coming from.'),
62 missing='Rhodecode',
62 missing='Rhodecode',
63 preparer=strip_whitespace,
63 preparer=strip_whitespace,
64 widget=deform.widget.TextInputWidget(
64 widget=deform.widget.TextInputWidget(
65 placeholder='Rhodecode'
65 placeholder='Rhodecode'
66 ),
66 ),
67 )
67 )
68 channel = colander.SchemaNode(
68 channel = colander.SchemaNode(
69 colander.String(),
69 colander.String(),
70 title=_('Channel'),
70 title=_('Channel'),
71 description=_('Channel to send notifications to.'),
71 description=_('Channel to send notifications to.'),
72 missing='',
72 missing='',
73 preparer=strip_whitespace,
73 preparer=strip_whitespace,
74 widget=deform.widget.TextInputWidget(
74 widget=deform.widget.TextInputWidget(
75 placeholder='#general'
75 placeholder='#general'
76 ),
76 ),
77 )
77 )
78 icon_emoji = colander.SchemaNode(
78 icon_emoji = colander.SchemaNode(
79 colander.String(),
79 colander.String(),
80 title=_('Emoji'),
80 title=_('Emoji'),
81 description=_('Emoji to use eg. :studio_microphone:'),
81 description=_('Emoji to use eg. :studio_microphone:'),
82 missing='',
82 missing='',
83 preparer=strip_whitespace,
83 preparer=strip_whitespace,
84 widget=deform.widget.TextInputWidget(
84 widget=deform.widget.TextInputWidget(
85 placeholder=':studio_microphone:'
85 placeholder=':studio_microphone:'
86 ),
86 ),
87 )
87 )
88
88
89
89
90 class SlackIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
90 class SlackIntegrationType(IntegrationTypeBase, CommitParsingDataHandler):
91 key = 'slack'
91 key = 'slack'
92 display_name = _('Slack')
92 display_name = _('Slack')
93 description = _('Send events such as repo pushes and pull requests to '
93 description = _('Send events such as repo pushes and pull requests to '
94 'your slack channel.')
94 'your slack channel.')
95
95
96 @classmethod
96 @classmethod
97 def icon(cls):
97 def icon(cls):
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>'''
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 valid_events = [
100 valid_events = [
101 events.PullRequestCloseEvent,
101 events.PullRequestCloseEvent,
102 events.PullRequestMergeEvent,
102 events.PullRequestMergeEvent,
103 events.PullRequestUpdateEvent,
103 events.PullRequestUpdateEvent,
104 events.PullRequestCommentEvent,
104 events.PullRequestCommentEvent,
105 events.PullRequestReviewEvent,
105 events.PullRequestReviewEvent,
106 events.PullRequestCreateEvent,
106 events.PullRequestCreateEvent,
107 events.RepoPushEvent,
107 events.RepoPushEvent,
108 events.RepoCreateEvent,
108 events.RepoCreateEvent,
109 ]
109 ]
110
110
111 def send_event(self, event):
111 def send_event(self, event):
112 if event.__class__ not in self.valid_events:
112 if event.__class__ not in self.valid_events:
113 log.debug('event not valid: %r' % event)
113 log.debug('event not valid: %r' % event)
114 return
114 return
115
115
116 if event.name not in self.settings['events']:
116 if event.name not in self.settings['events']:
117 log.debug('event ignored: %r' % event)
117 log.debug('event ignored: %r' % event)
118 return
118 return
119
119
120 data = event.as_dict()
120 data = event.as_dict()
121
121
122 # defaults
122 # defaults
123 title = '*%s* caused a *%s* event' % (
123 title = '*%s* caused a *%s* event' % (
124 data['actor']['username'], event.name)
124 data['actor']['username'], event.name)
125 text = '*%s* caused a *%s* event' % (
125 text = '*%s* caused a *%s* event' % (
126 data['actor']['username'], event.name)
126 data['actor']['username'], event.name)
127 fields = None
127 fields = None
128 overrides = None
128 overrides = None
129
129
130 log.debug('handling slack event for %s' % event.name)
130 log.debug('handling slack event for %s' % event.name)
131
131
132 if isinstance(event, events.PullRequestCommentEvent):
132 if isinstance(event, events.PullRequestCommentEvent):
133 (title, text, fields, overrides) \
133 (title, text, fields, overrides) \
134 = self.format_pull_request_comment_event(event, data)
134 = self.format_pull_request_comment_event(event, data)
135 elif isinstance(event, events.PullRequestReviewEvent):
135 elif isinstance(event, events.PullRequestReviewEvent):
136 title, text = self.format_pull_request_review_event(event, data)
136 title, text = self.format_pull_request_review_event(event, data)
137 elif isinstance(event, events.PullRequestEvent):
137 elif isinstance(event, events.PullRequestEvent):
138 title, text = self.format_pull_request_event(event, data)
138 title, text = self.format_pull_request_event(event, data)
139 elif isinstance(event, events.RepoPushEvent):
139 elif isinstance(event, events.RepoPushEvent):
140 title, text = self.format_repo_push_event(data)
140 title, text = self.format_repo_push_event(data)
141 elif isinstance(event, events.RepoCreateEvent):
141 elif isinstance(event, events.RepoCreateEvent):
142 title, text = self.format_repo_create_event(data)
142 title, text = self.format_repo_create_event(data)
143 else:
143 else:
144 log.error('unhandled event type: %r' % event)
144 log.error('unhandled event type: %r' % event)
145
145
146 run_task(post_text_to_slack, self.settings, title, text, fields, overrides)
146 run_task(post_text_to_slack, self.settings, title, text, fields, overrides)
147
147
148 def settings_schema(self):
148 def settings_schema(self):
149 schema = SlackSettingsSchema()
149 schema = SlackSettingsSchema()
150 schema.add(colander.SchemaNode(
150 schema.add(colander.SchemaNode(
151 colander.Set(),
151 colander.Set(),
152 widget=deform.widget.CheckboxChoiceWidget(
152 widget=deform.widget.CheckboxChoiceWidget(
153 values=sorted(
153 values=sorted(
154 [(e.name, e.display_name) for e in self.valid_events]
154 [(e.name, e.display_name) for e in self.valid_events]
155 )
155 )
156 ),
156 ),
157 description="Events activated for this integration",
157 description="Events activated for this integration",
158 name='events'
158 name='events'
159 ))
159 ))
160
160
161 return schema
161 return schema
162
162
163 def format_pull_request_comment_event(self, event, data):
163 def format_pull_request_comment_event(self, event, data):
164 comment_text = data['comment']['text']
164 comment_text = data['comment']['text']
165 if len(comment_text) > 200:
165 if len(comment_text) > 200:
166 comment_text = '<{comment_url}|{comment_text}...>'.format(
166 comment_text = '<{comment_url}|{comment_text}...>'.format(
167 comment_text=comment_text[:200],
167 comment_text=comment_text[:200],
168 comment_url=data['comment']['url'],
168 comment_url=data['comment']['url'],
169 )
169 )
170
170
171 fields = None
171 fields = None
172 overrides = None
172 overrides = None
173 status_text = None
173 status_text = None
174
174
175 if data['comment']['status']:
175 if data['comment']['status']:
176 status_color = {
176 status_color = {
177 'approved': '#0ac878',
177 'approved': '#0ac878',
178 'rejected': '#e85e4d'}.get(data['comment']['status'])
178 'rejected': '#e85e4d'}.get(data['comment']['status'])
179
179
180 if status_color:
180 if status_color:
181 overrides = {"color": status_color}
181 overrides = {"color": status_color}
182
182
183 status_text = data['comment']['status']
183 status_text = data['comment']['status']
184
184
185 if data['comment']['file']:
185 if data['comment']['file']:
186 fields = [
186 fields = [
187 {
187 {
188 "title": "file",
188 "title": "file",
189 "value": data['comment']['file']
189 "value": data['comment']['file']
190 },
190 },
191 {
191 {
192 "title": "line",
192 "title": "line",
193 "value": data['comment']['line']
193 "value": data['comment']['line']
194 }
194 }
195 ]
195 ]
196
196
197 template = Template(textwrap.dedent(r'''
197 template = Template(textwrap.dedent(r'''
198 *${data['actor']['username']}* left ${data['comment']['type']} on pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
198 *${data['actor']['username']}* left ${data['comment']['type']} on pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
199 '''))
199 '''))
200 title = render_with_traceback(
200 title = render_with_traceback(
201 template, data=data, comment=event.comment)
201 template, data=data, comment=event.comment)
202
202
203 template = Template(textwrap.dedent(r'''
203 template = Template(textwrap.dedent(r'''
204 *pull request title*: ${pr_title}
204 *pull request title*: ${pr_title}
205 % if status_text:
205 % if status_text:
206 *submitted status*: `${status_text}`
206 *submitted status*: `${status_text}`
207 % endif
207 % endif
208 >>> ${comment_text}
208 >>> ${comment_text}
209 '''))
209 '''))
210 text = render_with_traceback(
210 text = render_with_traceback(
211 template,
211 template,
212 comment_text=comment_text,
212 comment_text=comment_text,
213 pr_title=data['pullrequest']['title'],
213 pr_title=data['pullrequest']['title'],
214 status_text=status_text)
214 status_text=status_text)
215
215
216 return title, text, fields, overrides
216 return title, text, fields, overrides
217
217
218 def format_pull_request_review_event(self, event, data):
218 def format_pull_request_review_event(self, event, data):
219 template = Template(textwrap.dedent(r'''
219 template = Template(textwrap.dedent(r'''
220 *${data['actor']['username']}* changed status of pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']} to `${data['pullrequest']['status']}`>:
220 *${data['actor']['username']}* changed status of pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']} to `${data['pullrequest']['status']}`>:
221 '''))
221 '''))
222 title = render_with_traceback(template, data=data)
222 title = render_with_traceback(template, data=data)
223
223
224 template = Template(textwrap.dedent(r'''
224 template = Template(textwrap.dedent(r'''
225 *pull request title*: ${pr_title}
225 *pull request title*: ${pr_title}
226 '''))
226 '''))
227 text = render_with_traceback(
227 text = render_with_traceback(
228 template,
228 template,
229 pr_title=data['pullrequest']['title'])
229 pr_title=data['pullrequest']['title'])
230
230
231 return title, text
231 return title, text
232
232
233 def format_pull_request_event(self, event, data):
233 def format_pull_request_event(self, event, data):
234 action = {
234 action = {
235 events.PullRequestCloseEvent: 'closed',
235 events.PullRequestCloseEvent: 'closed',
236 events.PullRequestMergeEvent: 'merged',
236 events.PullRequestMergeEvent: 'merged',
237 events.PullRequestUpdateEvent: 'updated',
237 events.PullRequestUpdateEvent: 'updated',
238 events.PullRequestCreateEvent: 'created',
238 events.PullRequestCreateEvent: 'created',
239 }.get(event.__class__, str(event.__class__))
239 }.get(event.__class__, str(event.__class__))
240
240
241 template = Template(textwrap.dedent(r'''
241 template = Template(textwrap.dedent(r'''
242 *${data['actor']['username']}* `${action}` pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
242 *${data['actor']['username']}* `${action}` pull request <${data['pullrequest']['url']}|#${data['pullrequest']['pull_request_id']}>:
243 '''))
243 '''))
244 title = render_with_traceback(template, data=data, action=action)
244 title = render_with_traceback(template, data=data, action=action)
245
245
246 template = Template(textwrap.dedent(r'''
246 template = Template(textwrap.dedent(r'''
247 *pull request title*: ${pr_title}
247 *pull request title*: ${pr_title}
248 %if data['pullrequest']['commits']:
248 %if data['pullrequest']['commits']:
249 *commits*: ${len(data['pullrequest']['commits'])}
249 *commits*: ${len(data['pullrequest']['commits'])}
250 %endif
250 %endif
251 '''))
251 '''))
252 text = render_with_traceback(
252 text = render_with_traceback(
253 template,
253 template,
254 pr_title=data['pullrequest']['title'],
254 pr_title=data['pullrequest']['title'],
255 data=data)
255 data=data)
256
256
257 return title, text
257 return title, text
258
258
259 def format_repo_push_event(self, data):
259 def format_repo_push_event(self, data):
260
260
261 branches_commits = self.aggregate_branch_data(
261 branches_commits = self.aggregate_branch_data(
262 data['push']['branches'], data['push']['commits'])
262 data['push']['branches'], data['push']['commits'])
263
263
264 template = Template(r'''
264 template = Template(r'''
265 *${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
265 *${data['actor']['username']}* pushed to repo <${data['repo']['url']}|${data['repo']['repo_name']}>:
266 ''')
266 ''')
267 title = render_with_traceback(template, data=data)
267 title = render_with_traceback(template, data=data)
268
268
269 repo_push_template = Template(textwrap.dedent(r'''
269 repo_push_template = Template(textwrap.dedent(r'''
270 <%
270 <%
271 def branch_text(branch):
271 def branch_text(branch):
272 if branch:
272 if branch:
273 return 'on branch: <{}|{}>'.format(branch_commits['branch']['url'], branch_commits['branch']['name'])
273 return 'on branch: <{}|{}>'.format(branch_commits['branch']['url'], branch_commits['branch']['name'])
274 else:
274 else:
275 ## case for SVN no branch push...
275 ## case for SVN no branch push...
276 return 'to trunk'
276 return 'to trunk'
277 %> \
277 %> \
278 % for branch, branch_commits in branches_commits.items():
278 % for branch, branch_commits in branches_commits.items():
279 ${len(branch_commits['commits'])} ${'commit' if len(branch_commits['commits']) == 1 else 'commits'} ${branch_text(branch)}
279 ${len(branch_commits['commits'])} ${'commit' if len(branch_commits['commits']) == 1 else 'commits'} ${branch_text(branch)}
280 % for commit in branch_commits['commits']:
280 % for commit in branch_commits['commits']:
281 `<${commit['url']}|${commit['short_id']}>` - ${commit['message_html']|html_to_slack_links}
281 `<${commit['url']}|${commit['short_id']}>` - ${commit['message_html']|html_to_slack_links}
282 % endfor
282 % endfor
283 % endfor
283 % endfor
284 '''))
284 '''))
285
285
286 text = render_with_traceback(
286 text = render_with_traceback(
287 repo_push_template,
287 repo_push_template,
288 data=data,
288 data=data,
289 branches_commits=branches_commits,
289 branches_commits=branches_commits,
290 html_to_slack_links=html_to_slack_links,
290 html_to_slack_links=html_to_slack_links,
291 )
291 )
292
292
293 return title, text
293 return title, text
294
294
295 def format_repo_create_event(self, data):
295 def format_repo_create_event(self, data):
296 template = Template(r'''
296 template = Template(r'''
297 *${data['actor']['username']}* created new repository ${data['repo']['repo_name']}:
297 *${data['actor']['username']}* created new repository ${data['repo']['repo_name']}:
298 ''')
298 ''')
299 title = render_with_traceback(template, data=data)
299 title = render_with_traceback(template, data=data)
300
300
301 template = Template(textwrap.dedent(r'''
301 template = Template(textwrap.dedent(r'''
302 repo_url: ${data['repo']['url']}
302 repo_url: ${data['repo']['url']}
303 repo_type: ${data['repo']['repo_type']}
303 repo_type: ${data['repo']['repo_type']}
304 '''))
304 '''))
305 text = render_with_traceback(template, data=data)
305 text = render_with_traceback(template, data=data)
306
306
307 return title, text
307 return title, text
308
308
309
309
310 def html_to_slack_links(message):
310 def html_to_slack_links(message):
311 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
311 return re.compile(r'<a .*?href=["\'](.+?)".*?>(.+?)</a>').sub(
312 r'<\1|\2>', message)
312 r'<\1|\2>', message)
313
313
314
314
315 @async_task(ignore_result=True, base=RequestContextTask)
315 @async_task(ignore_result=True, base=RequestContextTask)
316 def post_text_to_slack(settings, title, text, fields=None, overrides=None):
316 def post_text_to_slack(settings, title, text, fields=None, overrides=None):
317 log.debug('sending %s (%s) to slack %s' % (
317 log.debug('sending %s (%s) to slack %s' % (
318 title, text, settings['service']))
318 title, text, settings['service']))
319
319
320 fields = fields or []
320 fields = fields or []
321 overrides = overrides or {}
321 overrides = overrides or {}
322
322
323 message_data = {
323 message_data = {
324 "fallback": text,
324 "fallback": text,
325 "color": "#427cc9",
325 "color": "#427cc9",
326 "pretext": title,
326 "pretext": title,
327 #"author_name": "Bobby Tables",
327 #"author_name": "Bobby Tables",
328 #"author_link": "http://flickr.com/bobby/",
328 #"author_link": "http://flickr.com/bobby/",
329 #"author_icon": "http://flickr.com/icons/bobby.jpg",
329 #"author_icon": "http://flickr.com/icons/bobby.jpg",
330 #"title": "Slack API Documentation",
330 #"title": "Slack API Documentation",
331 #"title_link": "https://api.slack.com/",
331 #"title_link": "https://api.slack.com/",
332 "text": text,
332 "text": text,
333 "fields": fields,
333 "fields": fields,
334 #"image_url": "http://my-website.com/path/to/image.jpg",
334 #"image_url": "http://my-website.com/path/to/image.jpg",
335 #"thumb_url": "http://example.com/path/to/thumb.png",
335 #"thumb_url": "http://example.com/path/to/thumb.png",
336 "footer": "RhodeCode",
336 "footer": "RhodeCode",
337 #"footer_icon": "",
337 #"footer_icon": "",
338 "ts": time.time(),
338 "ts": time.time(),
339 "mrkdwn_in": ["pretext", "text"]
339 "mrkdwn_in": ["pretext", "text"]
340 }
340 }
341 message_data.update(overrides)
341 message_data.update(overrides)
342 json_message = {
342 json_message = {
343 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:'),
343 "icon_emoji": settings.get('icon_emoji', ':studio_microphone:'),
344 "channel": settings.get('channel', ''),
344 "channel": settings.get('channel', ''),
345 "username": settings.get('username', 'Rhodecode'),
345 "username": settings.get('username', 'Rhodecode'),
346 "attachments": [message_data]
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 resp.raise_for_status() # raise exception on a failed request
350 resp.raise_for_status() # raise exception on a failed request
@@ -1,274 +1,274 b''
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2
2
3 # Copyright (C) 2012-2018 RhodeCode GmbH
3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 #
4 #
5 # This program is free software: you can redistribute it and/or modify
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License, version 3
6 # it under the terms of the GNU Affero General Public License, version 3
7 # (only), as published by the Free Software Foundation.
7 # (only), as published by the Free Software Foundation.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU Affero General Public License
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 #
16 #
17 # This program is dual-licensed. If you wish to learn more about the
17 # This program is dual-licensed. If you wish to learn more about the
18 # RhodeCode Enterprise Edition, including its added features, Support services,
18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20
20
21 from __future__ import unicode_literals
21 from __future__ import unicode_literals
22
22
23 import deform
23 import deform
24 import deform.widget
24 import deform.widget
25 import logging
25 import logging
26 import requests
26 import requests
27 import requests.adapters
27 import requests.adapters
28 import colander
28 import colander
29 from requests.packages.urllib3.util.retry import Retry
29 from requests.packages.urllib3.util.retry import Retry
30
30
31 import rhodecode
31 import rhodecode
32 from rhodecode import events
32 from rhodecode import events
33 from rhodecode.translation import _
33 from rhodecode.translation import _
34 from rhodecode.integrations.types.base import (
34 from rhodecode.integrations.types.base import (
35 IntegrationTypeBase, get_auth, get_web_token, get_url_vars,
35 IntegrationTypeBase, get_auth, get_web_token, get_url_vars,
36 WebhookDataHandler, WEBHOOK_URL_VARS)
36 WebhookDataHandler, WEBHOOK_URL_VARS)
37 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
37 from rhodecode.lib.celerylib import run_task, async_task, RequestContextTask
38 from rhodecode.model.validation_schema import widgets
38 from rhodecode.model.validation_schema import widgets
39
39
40 log = logging.getLogger(__name__)
40 log = logging.getLogger(__name__)
41
41
42
42
43 # updating this required to update the `common_vars` passed in url calling func
43 # updating this required to update the `common_vars` passed in url calling func
44
44
45 URL_VARS = get_url_vars(WEBHOOK_URL_VARS)
45 URL_VARS = get_url_vars(WEBHOOK_URL_VARS)
46
46
47
47
48 class WebhookSettingsSchema(colander.Schema):
48 class WebhookSettingsSchema(colander.Schema):
49 url = colander.SchemaNode(
49 url = colander.SchemaNode(
50 colander.String(),
50 colander.String(),
51 title=_('Webhook URL'),
51 title=_('Webhook URL'),
52 description=
52 description=
53 _('URL to which Webhook should submit data. If used some of the '
53 _('URL to which Webhook should submit data. If used some of the '
54 'variables would trigger multiple calls, like ${branch} or '
54 'variables would trigger multiple calls, like ${branch} or '
55 '${commit_id}. Webhook will be called as many times as unique '
55 '${commit_id}. Webhook will be called as many times as unique '
56 'objects in data in such cases.'),
56 'objects in data in such cases.'),
57 missing=colander.required,
57 missing=colander.required,
58 required=True,
58 required=True,
59 validator=colander.url,
59 validator=colander.url,
60 widget=widgets.CodeMirrorWidget(
60 widget=widgets.CodeMirrorWidget(
61 help_block_collapsable_name='Show url variables',
61 help_block_collapsable_name='Show url variables',
62 help_block_collapsable=(
62 help_block_collapsable=(
63 'E.g http://my-serv/trigger_job/${{event_name}}'
63 'E.g http://my-serv/trigger_job/${{event_name}}'
64 '?PR_ID=${{pull_request_id}}'
64 '?PR_ID=${{pull_request_id}}'
65 '\nFull list of vars:\n{}'.format(URL_VARS)),
65 '\nFull list of vars:\n{}'.format(URL_VARS)),
66 codemirror_mode='text',
66 codemirror_mode='text',
67 codemirror_options='{"lineNumbers": false, "lineWrapping": true}'),
67 codemirror_options='{"lineNumbers": false, "lineWrapping": true}'),
68 )
68 )
69 secret_token = colander.SchemaNode(
69 secret_token = colander.SchemaNode(
70 colander.String(),
70 colander.String(),
71 title=_('Secret Token'),
71 title=_('Secret Token'),
72 description=_('Optional string used to validate received payloads. '
72 description=_('Optional string used to validate received payloads. '
73 'It will be sent together with event data in JSON'),
73 'It will be sent together with event data in JSON'),
74 default='',
74 default='',
75 missing='',
75 missing='',
76 widget=deform.widget.TextInputWidget(
76 widget=deform.widget.TextInputWidget(
77 placeholder='e.g. secret_token'
77 placeholder='e.g. secret_token'
78 ),
78 ),
79 )
79 )
80 username = colander.SchemaNode(
80 username = colander.SchemaNode(
81 colander.String(),
81 colander.String(),
82 title=_('Username'),
82 title=_('Username'),
83 description=_('Optional username to authenticate the call.'),
83 description=_('Optional username to authenticate the call.'),
84 default='',
84 default='',
85 missing='',
85 missing='',
86 widget=deform.widget.TextInputWidget(
86 widget=deform.widget.TextInputWidget(
87 placeholder='e.g. admin'
87 placeholder='e.g. admin'
88 ),
88 ),
89 )
89 )
90 password = colander.SchemaNode(
90 password = colander.SchemaNode(
91 colander.String(),
91 colander.String(),
92 title=_('Password'),
92 title=_('Password'),
93 description=_('Optional password to authenticate the call.'),
93 description=_('Optional password to authenticate the call.'),
94 default='',
94 default='',
95 missing='',
95 missing='',
96 widget=deform.widget.PasswordWidget(
96 widget=deform.widget.PasswordWidget(
97 placeholder='e.g. secret.',
97 placeholder='e.g. secret.',
98 redisplay=True,
98 redisplay=True,
99 ),
99 ),
100 )
100 )
101 custom_header_key = colander.SchemaNode(
101 custom_header_key = colander.SchemaNode(
102 colander.String(),
102 colander.String(),
103 title=_('Custom Header Key'),
103 title=_('Custom Header Key'),
104 description=_('Custom Header name to be set when calling endpoint.'),
104 description=_('Custom Header name to be set when calling endpoint.'),
105 default='',
105 default='',
106 missing='',
106 missing='',
107 widget=deform.widget.TextInputWidget(
107 widget=deform.widget.TextInputWidget(
108 placeholder='e.g: Authorization'
108 placeholder='e.g: Authorization'
109 ),
109 ),
110 )
110 )
111 custom_header_val = colander.SchemaNode(
111 custom_header_val = colander.SchemaNode(
112 colander.String(),
112 colander.String(),
113 title=_('Custom Header Value'),
113 title=_('Custom Header Value'),
114 description=_('Custom Header value to be set when calling endpoint.'),
114 description=_('Custom Header value to be set when calling endpoint.'),
115 default='',
115 default='',
116 missing='',
116 missing='',
117 widget=deform.widget.TextInputWidget(
117 widget=deform.widget.TextInputWidget(
118 placeholder='e.g. Basic XxXxXx'
118 placeholder='e.g. Basic XxXxXx'
119 ),
119 ),
120 )
120 )
121 method_type = colander.SchemaNode(
121 method_type = colander.SchemaNode(
122 colander.String(),
122 colander.String(),
123 title=_('Call Method'),
123 title=_('Call Method'),
124 description=_('Select if the Webhook call should be made '
124 description=_('Select if the Webhook call should be made '
125 'with POST or GET.'),
125 'with POST or GET.'),
126 default='post',
126 default='post',
127 missing='',
127 missing='',
128 widget=deform.widget.RadioChoiceWidget(
128 widget=deform.widget.RadioChoiceWidget(
129 values=[('get', 'GET'), ('post', 'POST')],
129 values=[('get', 'GET'), ('post', 'POST')],
130 inline=True
130 inline=True
131 ),
131 ),
132 )
132 )
133
133
134
134
135 class WebhookIntegrationType(IntegrationTypeBase):
135 class WebhookIntegrationType(IntegrationTypeBase):
136 key = 'webhook'
136 key = 'webhook'
137 display_name = _('Webhook')
137 display_name = _('Webhook')
138 description = _('send JSON data to a url endpoint')
138 description = _('send JSON data to a url endpoint')
139
139
140 @classmethod
140 @classmethod
141 def icon(cls):
141 def icon(cls):
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>'''
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 valid_events = [
144 valid_events = [
145 events.PullRequestCloseEvent,
145 events.PullRequestCloseEvent,
146 events.PullRequestMergeEvent,
146 events.PullRequestMergeEvent,
147 events.PullRequestUpdateEvent,
147 events.PullRequestUpdateEvent,
148 events.PullRequestCommentEvent,
148 events.PullRequestCommentEvent,
149 events.PullRequestReviewEvent,
149 events.PullRequestReviewEvent,
150 events.PullRequestCreateEvent,
150 events.PullRequestCreateEvent,
151 events.RepoPushEvent,
151 events.RepoPushEvent,
152 events.RepoCreateEvent,
152 events.RepoCreateEvent,
153 ]
153 ]
154
154
155 def settings_schema(self):
155 def settings_schema(self):
156 schema = WebhookSettingsSchema()
156 schema = WebhookSettingsSchema()
157 schema.add(colander.SchemaNode(
157 schema.add(colander.SchemaNode(
158 colander.Set(),
158 colander.Set(),
159 widget=deform.widget.CheckboxChoiceWidget(
159 widget=deform.widget.CheckboxChoiceWidget(
160 values=sorted(
160 values=sorted(
161 [(e.name, e.display_name) for e in self.valid_events]
161 [(e.name, e.display_name) for e in self.valid_events]
162 )
162 )
163 ),
163 ),
164 description="Events activated for this integration",
164 description="Events activated for this integration",
165 name='events'
165 name='events'
166 ))
166 ))
167 return schema
167 return schema
168
168
169 def send_event(self, event):
169 def send_event(self, event):
170 log.debug(
170 log.debug(
171 'handling event %s with Webhook integration %s', event.name, self)
171 'handling event %s with Webhook integration %s', event.name, self)
172
172
173 if event.__class__ not in self.valid_events:
173 if event.__class__ not in self.valid_events:
174 log.debug('event not valid: %r' % event)
174 log.debug('event not valid: %r' % event)
175 return
175 return
176
176
177 if event.name not in self.settings['events']:
177 if event.name not in self.settings['events']:
178 log.debug('event ignored: %r' % event)
178 log.debug('event ignored: %r' % event)
179 return
179 return
180
180
181 data = event.as_dict()
181 data = event.as_dict()
182 template_url = self.settings['url']
182 template_url = self.settings['url']
183
183
184 headers = {}
184 headers = {}
185 head_key = self.settings.get('custom_header_key')
185 head_key = self.settings.get('custom_header_key')
186 head_val = self.settings.get('custom_header_val')
186 head_val = self.settings.get('custom_header_val')
187 if head_key and head_val:
187 if head_key and head_val:
188 headers = {head_key: head_val}
188 headers = {head_key: head_val}
189
189
190 handler = WebhookDataHandler(template_url, headers)
190 handler = WebhookDataHandler(template_url, headers)
191
191
192 url_calls = handler(event, data)
192 url_calls = handler(event, data)
193 log.debug('webhook: calling following urls: %s',
193 log.debug('webhook: calling following urls: %s',
194 [x[0] for x in url_calls])
194 [x[0] for x in url_calls])
195
195
196 run_task(post_to_webhook, url_calls, self.settings)
196 run_task(post_to_webhook, url_calls, self.settings)
197
197
198
198
199 @async_task(ignore_result=True, base=RequestContextTask)
199 @async_task(ignore_result=True, base=RequestContextTask)
200 def post_to_webhook(url_calls, settings):
200 def post_to_webhook(url_calls, settings):
201 """
201 """
202 Example data::
202 Example data::
203
203
204 {'actor': {'user_id': 2, 'username': u'admin'},
204 {'actor': {'user_id': 2, 'username': u'admin'},
205 'actor_ip': u'192.168.157.1',
205 'actor_ip': u'192.168.157.1',
206 'name': 'repo-push',
206 'name': 'repo-push',
207 'push': {'branches': [{'name': u'default',
207 'push': {'branches': [{'name': u'default',
208 'url': 'http://rc.local:8080/hg-repo/changelog?branch=default'}],
208 'url': 'http://rc.local:8080/hg-repo/changelog?branch=default'}],
209 'commits': [{'author': u'Marcin Kuzminski <marcin@rhodecode.com>',
209 'commits': [{'author': u'Marcin Kuzminski <marcin@rhodecode.com>',
210 'branch': u'default',
210 'branch': u'default',
211 'date': datetime.datetime(2017, 11, 30, 12, 59, 48),
211 'date': datetime.datetime(2017, 11, 30, 12, 59, 48),
212 'issues': [],
212 'issues': [],
213 'mentions': [],
213 'mentions': [],
214 'message': u'commit Thu 30 Nov 2017 13:59:48 CET',
214 'message': u'commit Thu 30 Nov 2017 13:59:48 CET',
215 'message_html': u'commit Thu 30 Nov 2017 13:59:48 CET',
215 'message_html': u'commit Thu 30 Nov 2017 13:59:48 CET',
216 'message_html_title': u'commit Thu 30 Nov 2017 13:59:48 CET',
216 'message_html_title': u'commit Thu 30 Nov 2017 13:59:48 CET',
217 'parents': [{'raw_id': '431b772a5353dad9974b810dd3707d79e3a7f6e0'}],
217 'parents': [{'raw_id': '431b772a5353dad9974b810dd3707d79e3a7f6e0'}],
218 'permalink_url': u'http://rc.local:8080/_7/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
218 'permalink_url': u'http://rc.local:8080/_7/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
219 'raw_id': 'a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
219 'raw_id': 'a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf',
220 'refs': {'bookmarks': [], 'branches': [u'default'], 'tags': [u'tip']},
220 'refs': {'bookmarks': [], 'branches': [u'default'], 'tags': [u'tip']},
221 'reviewers': [],
221 'reviewers': [],
222 'revision': 9L,
222 'revision': 9L,
223 'short_id': 'a815cc738b96',
223 'short_id': 'a815cc738b96',
224 'url': u'http://rc.local:8080/hg-repo/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf'}],
224 'url': u'http://rc.local:8080/hg-repo/changeset/a815cc738b9651eb5ffbcfb1ce6ccd7c701a5ddf'}],
225 'issues': {}},
225 'issues': {}},
226 'repo': {'extra_fields': '',
226 'repo': {'extra_fields': '',
227 'permalink_url': u'http://rc.local:8080/_7',
227 'permalink_url': u'http://rc.local:8080/_7',
228 'repo_id': 7,
228 'repo_id': 7,
229 'repo_name': u'hg-repo',
229 'repo_name': u'hg-repo',
230 'repo_type': u'hg',
230 'repo_type': u'hg',
231 'url': u'http://rc.local:8080/hg-repo'},
231 'url': u'http://rc.local:8080/hg-repo'},
232 'server_url': u'http://rc.local:8080',
232 'server_url': u'http://rc.local:8080',
233 'utc_timestamp': datetime.datetime(2017, 11, 30, 13, 0, 1, 569276)
233 'utc_timestamp': datetime.datetime(2017, 11, 30, 13, 0, 1, 569276)
234
234
235 """
235 """
236 max_retries = 3
236 max_retries = 3
237 retries = Retry(
237 retries = Retry(
238 total=max_retries,
238 total=max_retries,
239 backoff_factor=0.15,
239 backoff_factor=0.15,
240 status_forcelist=[500, 502, 503, 504])
240 status_forcelist=[500, 502, 503, 504])
241 call_headers = {
241 call_headers = {
242 'User-Agent': 'RhodeCode-webhook-caller/{}'.format(
242 'User-Agent': 'RhodeCode-webhook-caller/{}'.format(
243 rhodecode.__version__)
243 rhodecode.__version__)
244 } # updated below with custom ones, allows override
244 } # updated below with custom ones, allows override
245
245
246 auth = get_auth(settings)
246 auth = get_auth(settings)
247 token = get_web_token(settings)
247 token = get_web_token(settings)
248
248
249 for url, headers, data in url_calls:
249 for url, headers, data in url_calls:
250 req_session = requests.Session()
250 req_session = requests.Session()
251 req_session.mount( # retry max N times
251 req_session.mount( # retry max N times
252 'http://', requests.adapters.HTTPAdapter(max_retries=retries))
252 'http://', requests.adapters.HTTPAdapter(max_retries=retries))
253
253
254 method = settings.get('method_type') or 'post'
254 method = settings.get('method_type') or 'post'
255 call_method = getattr(req_session, method)
255 call_method = getattr(req_session, method)
256
256
257 headers = headers or {}
257 headers = headers or {}
258 call_headers.update(headers)
258 call_headers.update(headers)
259
259
260 log.debug('calling Webhook with method: %s, and auth:%s',
260 log.debug('calling Webhook with method: %s, and auth:%s',
261 call_method, auth)
261 call_method, auth)
262 if settings.get('log_data'):
262 if settings.get('log_data'):
263 log.debug('calling webhook with data: %s', data)
263 log.debug('calling webhook with data: %s', data)
264 resp = call_method(url, json={
264 resp = call_method(url, json={
265 'token': token,
265 'token': token,
266 'event': data
266 'event': data
267 }, headers=call_headers, auth=auth)
267 }, headers=call_headers, auth=auth, timeout=60)
268 log.debug('Got Webhook response: %s', resp)
268 log.debug('Got Webhook response: %s', resp)
269
269
270 try:
270 try:
271 resp.raise_for_status() # raise exception on a failed request
271 resp.raise_for_status() # raise exception on a failed request
272 except Exception:
272 except Exception:
273 log.error(resp.text)
273 log.error(resp.text)
274 raise
274 raise
General Comments 0
You need to be logged in to leave comments. Login now