##// END OF EJS Templates
python3: fixed urllib usage
super-admin -
r4950:e4140a41 default
parent child Browse files
Show More
@@ -1,470 +1,470 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import time
22 22 import collections
23 23 import datetime
24 24 import formencode
25 25 import formencode.htmlfill
26 26 import logging
27 27 import urllib.parse
28 28 import requests
29 29
30 30 from pyramid.httpexceptions import HTTPFound
31 31
32 32
33 33 from rhodecode.apps._base import BaseAppView
34 34 from rhodecode.authentication.base import authenticate, HTTP_TYPE
35 35 from rhodecode.authentication.plugins import auth_rhodecode
36 36 from rhodecode.events import UserRegistered, trigger
37 37 from rhodecode.lib import helpers as h
38 38 from rhodecode.lib import audit_logger
39 39 from rhodecode.lib.auth import (
40 40 AuthUser, HasPermissionAnyDecorator, CSRFRequired)
41 41 from rhodecode.lib.base import get_ip_addr
42 42 from rhodecode.lib.exceptions import UserCreationError
43 43 from rhodecode.lib.utils2 import safe_str
44 44 from rhodecode.model.db import User, UserApiKeys
45 45 from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm
46 46 from rhodecode.model.meta import Session
47 47 from rhodecode.model.auth_token import AuthTokenModel
48 48 from rhodecode.model.settings import SettingsModel
49 49 from rhodecode.model.user import UserModel
50 50 from rhodecode.translation import _
51 51
52 52
53 53 log = logging.getLogger(__name__)
54 54
55 55 CaptchaData = collections.namedtuple(
56 56 'CaptchaData', 'active, private_key, public_key')
57 57
58 58
59 59 def store_user_in_session(session, username, remember=False):
60 60 user = User.get_by_username(username, case_insensitive=True)
61 61 auth_user = AuthUser(user.user_id)
62 62 auth_user.set_authenticated()
63 63 cs = auth_user.get_cookie_store()
64 64 session['rhodecode_user'] = cs
65 65 user.update_lastlogin()
66 66 Session().commit()
67 67
68 68 # If they want to be remembered, update the cookie
69 69 if remember:
70 70 _year = (datetime.datetime.now() +
71 71 datetime.timedelta(seconds=60 * 60 * 24 * 365))
72 72 session._set_cookie_expires(_year)
73 73
74 74 session.save()
75 75
76 76 safe_cs = cs.copy()
77 77 safe_cs['password'] = '****'
78 78 log.info('user %s is now authenticated and stored in '
79 79 'session, session attrs %s', username, safe_cs)
80 80
81 81 # dumps session attrs back to cookie
82 82 session._update_cookie_out()
83 83 # we set new cookie
84 84 headers = None
85 85 if session.request['set_cookie']:
86 86 # send set-cookie headers back to response to update cookie
87 87 headers = [('Set-Cookie', session.request['cookie_out'])]
88 88 return headers
89 89
90 90
91 91 def get_came_from(request):
92 92 came_from = safe_str(request.GET.get('came_from', ''))
93 parsed = urllib.parse.urlparse.urlparse(came_from)
93 parsed = urllib.parse.urlparse(came_from)
94 94 allowed_schemes = ['http', 'https']
95 95 default_came_from = h.route_path('home')
96 96 if parsed.scheme and parsed.scheme not in allowed_schemes:
97 97 log.error('Suspicious URL scheme detected %s for url %s',
98 98 parsed.scheme, parsed)
99 99 came_from = default_came_from
100 100 elif parsed.netloc and request.host != parsed.netloc:
101 101 log.error('Suspicious NETLOC detected %s for url %s server url '
102 102 'is: %s', parsed.netloc, parsed, request.host)
103 103 came_from = default_came_from
104 104 elif any(bad_str in parsed.path for bad_str in ('\r', '\n')):
105 105 log.error('Header injection detected `%s` for url %s server url ',
106 106 parsed.path, parsed)
107 107 came_from = default_came_from
108 108
109 109 return came_from or default_came_from
110 110
111 111
112 112 class LoginView(BaseAppView):
113 113
114 114 def load_default_context(self):
115 115 c = self._get_local_tmpl_context()
116 116 c.came_from = get_came_from(self.request)
117 117 return c
118 118
119 119 def _get_captcha_data(self):
120 120 settings = SettingsModel().get_all_settings()
121 121 private_key = settings.get('rhodecode_captcha_private_key')
122 122 public_key = settings.get('rhodecode_captcha_public_key')
123 123 active = bool(private_key)
124 124 return CaptchaData(
125 125 active=active, private_key=private_key, public_key=public_key)
126 126
127 127 def validate_captcha(self, private_key):
128 128
129 129 captcha_rs = self.request.POST.get('g-recaptcha-response')
130 130 url = "https://www.google.com/recaptcha/api/siteverify"
131 131 params = {
132 132 'secret': private_key,
133 133 'response': captcha_rs,
134 134 'remoteip': get_ip_addr(self.request.environ)
135 135 }
136 136 verify_rs = requests.get(url, params=params, verify=True, timeout=60)
137 137 verify_rs = verify_rs.json()
138 138 captcha_status = verify_rs.get('success', False)
139 139 captcha_errors = verify_rs.get('error-codes', [])
140 140 if not isinstance(captcha_errors, list):
141 141 captcha_errors = [captcha_errors]
142 142 captcha_errors = ', '.join(captcha_errors)
143 143 captcha_message = ''
144 144 if captcha_status is False:
145 145 captcha_message = "Bad captcha. Errors: {}".format(
146 146 captcha_errors)
147 147
148 148 return captcha_status, captcha_message
149 149
150 150 def login(self):
151 151 c = self.load_default_context()
152 152 auth_user = self._rhodecode_user
153 153
154 154 # redirect if already logged in
155 155 if (auth_user.is_authenticated and
156 156 not auth_user.is_default and auth_user.ip_allowed):
157 157 raise HTTPFound(c.came_from)
158 158
159 159 # check if we use headers plugin, and try to login using it.
160 160 try:
161 161 log.debug('Running PRE-AUTH for headers based authentication')
162 162 auth_info = authenticate(
163 163 '', '', self.request.environ, HTTP_TYPE, skip_missing=True)
164 164 if auth_info:
165 165 headers = store_user_in_session(
166 166 self.session, auth_info.get('username'))
167 167 raise HTTPFound(c.came_from, headers=headers)
168 168 except UserCreationError as e:
169 169 log.error(e)
170 170 h.flash(e, category='error')
171 171
172 172 return self._get_template_context(c)
173 173
174 174 def login_post(self):
175 175 c = self.load_default_context()
176 176
177 177 login_form = LoginForm(self.request.translate)()
178 178
179 179 try:
180 180 self.session.invalidate()
181 181 form_result = login_form.to_python(self.request.POST)
182 182 # form checks for username/password, now we're authenticated
183 183 headers = store_user_in_session(
184 184 self.session,
185 185 username=form_result['username'],
186 186 remember=form_result['remember'])
187 187 log.debug('Redirecting to "%s" after login.', c.came_from)
188 188
189 189 audit_user = audit_logger.UserWrap(
190 190 username=self.request.POST.get('username'),
191 191 ip_addr=self.request.remote_addr)
192 192 action_data = {'user_agent': self.request.user_agent}
193 193 audit_logger.store_web(
194 194 'user.login.success', action_data=action_data,
195 195 user=audit_user, commit=True)
196 196
197 197 raise HTTPFound(c.came_from, headers=headers)
198 198 except formencode.Invalid as errors:
199 199 defaults = errors.value
200 200 # remove password from filling in form again
201 201 defaults.pop('password', None)
202 202 render_ctx = {
203 203 'errors': errors.error_dict,
204 204 'defaults': defaults,
205 205 }
206 206
207 207 audit_user = audit_logger.UserWrap(
208 208 username=self.request.POST.get('username'),
209 209 ip_addr=self.request.remote_addr)
210 210 action_data = {'user_agent': self.request.user_agent}
211 211 audit_logger.store_web(
212 212 'user.login.failure', action_data=action_data,
213 213 user=audit_user, commit=True)
214 214 return self._get_template_context(c, **render_ctx)
215 215
216 216 except UserCreationError as e:
217 217 # headers auth or other auth functions that create users on
218 218 # the fly can throw this exception signaling that there's issue
219 219 # with user creation, explanation should be provided in
220 220 # Exception itself
221 221 h.flash(e, category='error')
222 222 return self._get_template_context(c)
223 223
224 224 @CSRFRequired()
225 225 def logout(self):
226 226 auth_user = self._rhodecode_user
227 227 log.info('Deleting session for user: `%s`', auth_user)
228 228
229 229 action_data = {'user_agent': self.request.user_agent}
230 230 audit_logger.store_web(
231 231 'user.logout', action_data=action_data,
232 232 user=auth_user, commit=True)
233 233 self.session.delete()
234 234 return HTTPFound(h.route_path('home'))
235 235
236 236 @HasPermissionAnyDecorator(
237 237 'hg.admin', 'hg.register.auto_activate', 'hg.register.manual_activate')
238 238 def register(self, defaults=None, errors=None):
239 239 c = self.load_default_context()
240 240 defaults = defaults or {}
241 241 errors = errors or {}
242 242
243 243 settings = SettingsModel().get_all_settings()
244 244 register_message = settings.get('rhodecode_register_message') or ''
245 245 captcha = self._get_captcha_data()
246 246 auto_active = 'hg.register.auto_activate' in User.get_default_user()\
247 247 .AuthUser().permissions['global']
248 248
249 249 render_ctx = self._get_template_context(c)
250 250 render_ctx.update({
251 251 'defaults': defaults,
252 252 'errors': errors,
253 253 'auto_active': auto_active,
254 254 'captcha_active': captcha.active,
255 255 'captcha_public_key': captcha.public_key,
256 256 'register_message': register_message,
257 257 })
258 258 return render_ctx
259 259
260 260 @HasPermissionAnyDecorator(
261 261 'hg.admin', 'hg.register.auto_activate', 'hg.register.manual_activate')
262 262 def register_post(self):
263 263 from rhodecode.authentication.plugins import auth_rhodecode
264 264
265 265 self.load_default_context()
266 266 captcha = self._get_captcha_data()
267 267 auto_active = 'hg.register.auto_activate' in User.get_default_user()\
268 268 .AuthUser().permissions['global']
269 269
270 270 extern_name = auth_rhodecode.RhodeCodeAuthPlugin.uid
271 271 extern_type = auth_rhodecode.RhodeCodeAuthPlugin.uid
272 272
273 273 register_form = RegisterForm(self.request.translate)()
274 274 try:
275 275
276 276 form_result = register_form.to_python(self.request.POST)
277 277 form_result['active'] = auto_active
278 278 external_identity = self.request.POST.get('external_identity')
279 279
280 280 if external_identity:
281 281 extern_name = external_identity
282 282 extern_type = external_identity
283 283
284 284 if captcha.active:
285 285 captcha_status, captcha_message = self.validate_captcha(
286 286 captcha.private_key)
287 287
288 288 if not captcha_status:
289 289 _value = form_result
290 290 _msg = _('Bad captcha')
291 291 error_dict = {'recaptcha_field': captcha_message}
292 292 raise formencode.Invalid(
293 293 _msg, _value, None, error_dict=error_dict)
294 294
295 295 new_user = UserModel().create_registration(
296 296 form_result, extern_name=extern_name, extern_type=extern_type)
297 297
298 298 action_data = {'data': new_user.get_api_data(),
299 299 'user_agent': self.request.user_agent}
300 300
301 301 if external_identity:
302 302 action_data['external_identity'] = external_identity
303 303
304 304 audit_user = audit_logger.UserWrap(
305 305 username=new_user.username,
306 306 user_id=new_user.user_id,
307 307 ip_addr=self.request.remote_addr)
308 308
309 309 audit_logger.store_web(
310 310 'user.register', action_data=action_data,
311 311 user=audit_user)
312 312
313 313 event = UserRegistered(user=new_user, session=self.session)
314 314 trigger(event)
315 315 h.flash(
316 316 _('You have successfully registered with RhodeCode. You can log-in now.'),
317 317 category='success')
318 318 if external_identity:
319 319 h.flash(
320 320 _('Please use the {identity} button to log-in').format(
321 321 identity=external_identity),
322 322 category='success')
323 323 Session().commit()
324 324
325 325 redirect_ro = self.request.route_path('login')
326 326 raise HTTPFound(redirect_ro)
327 327
328 328 except formencode.Invalid as errors:
329 329 errors.value.pop('password', None)
330 330 errors.value.pop('password_confirmation', None)
331 331 return self.register(
332 332 defaults=errors.value, errors=errors.error_dict)
333 333
334 334 except UserCreationError as e:
335 335 # container auth or other auth functions that create users on
336 336 # the fly can throw this exception signaling that there's issue
337 337 # with user creation, explanation should be provided in
338 338 # Exception itself
339 339 h.flash(e, category='error')
340 340 return self.register()
341 341
342 342 def password_reset(self):
343 343 c = self.load_default_context()
344 344 captcha = self._get_captcha_data()
345 345
346 346 template_context = {
347 347 'captcha_active': captcha.active,
348 348 'captcha_public_key': captcha.public_key,
349 349 'defaults': {},
350 350 'errors': {},
351 351 }
352 352
353 353 # always send implicit message to prevent from discovery of
354 354 # matching emails
355 355 msg = _('If such email exists, a password reset link was sent to it.')
356 356
357 357 def default_response():
358 358 log.debug('faking response on invalid password reset')
359 359 # make this take 2s, to prevent brute forcing.
360 360 time.sleep(2)
361 361 h.flash(msg, category='success')
362 362 return HTTPFound(self.request.route_path('reset_password'))
363 363
364 364 if self.request.POST:
365 365 if h.HasPermissionAny('hg.password_reset.disabled')():
366 366 _email = self.request.POST.get('email', '')
367 367 log.error('Failed attempt to reset password for `%s`.', _email)
368 368 h.flash(_('Password reset has been disabled.'), category='error')
369 369 return HTTPFound(self.request.route_path('reset_password'))
370 370
371 371 password_reset_form = PasswordResetForm(self.request.translate)()
372 372 description = u'Generated token for password reset from {}'.format(
373 373 datetime.datetime.now().isoformat())
374 374
375 375 try:
376 376 form_result = password_reset_form.to_python(
377 377 self.request.POST)
378 378 user_email = form_result['email']
379 379
380 380 if captcha.active:
381 381 captcha_status, captcha_message = self.validate_captcha(
382 382 captcha.private_key)
383 383
384 384 if not captcha_status:
385 385 _value = form_result
386 386 _msg = _('Bad captcha')
387 387 error_dict = {'recaptcha_field': captcha_message}
388 388 raise formencode.Invalid(
389 389 _msg, _value, None, error_dict=error_dict)
390 390
391 391 # Generate reset URL and send mail.
392 392 user = User.get_by_email(user_email)
393 393
394 394 # only allow rhodecode based users to reset their password
395 395 # external auth shouldn't allow password reset
396 396 if user and user.extern_type != auth_rhodecode.RhodeCodeAuthPlugin.uid:
397 397 log.warning('User %s with external type `%s` tried a password reset. '
398 398 'This try was rejected', user, user.extern_type)
399 399 return default_response()
400 400
401 401 # generate password reset token that expires in 10 minutes
402 402 reset_token = UserModel().add_auth_token(
403 403 user=user, lifetime_minutes=10,
404 404 role=UserModel.auth_token_role.ROLE_PASSWORD_RESET,
405 405 description=description)
406 406 Session().commit()
407 407
408 408 log.debug('Successfully created password recovery token')
409 409 password_reset_url = self.request.route_url(
410 410 'reset_password_confirmation',
411 411 _query={'key': reset_token.api_key})
412 412 UserModel().reset_password_link(
413 413 form_result, password_reset_url)
414 414
415 415 action_data = {'email': user_email,
416 416 'user_agent': self.request.user_agent}
417 417 audit_logger.store_web(
418 418 'user.password.reset_request', action_data=action_data,
419 419 user=self._rhodecode_user, commit=True)
420 420
421 421 return default_response()
422 422
423 423 except formencode.Invalid as errors:
424 424 template_context.update({
425 425 'defaults': errors.value,
426 426 'errors': errors.error_dict,
427 427 })
428 428 if not self.request.POST.get('email'):
429 429 # case of empty email, we want to report that
430 430 return self._get_template_context(c, **template_context)
431 431
432 432 if 'recaptcha_field' in errors.error_dict:
433 433 # case of failed captcha
434 434 return self._get_template_context(c, **template_context)
435 435
436 436 return default_response()
437 437
438 438 return self._get_template_context(c, **template_context)
439 439
440 440 def password_reset_confirmation(self):
441 441 self.load_default_context()
442 442 if self.request.GET and self.request.GET.get('key'):
443 443 # make this take 2s, to prevent brute forcing.
444 444 time.sleep(2)
445 445
446 446 token = AuthTokenModel().get_auth_token(
447 447 self.request.GET.get('key'))
448 448
449 449 # verify token is the correct role
450 450 if token is None or token.role != UserApiKeys.ROLE_PASSWORD_RESET:
451 451 log.debug('Got token with role:%s expected is %s',
452 452 getattr(token, 'role', 'EMPTY_TOKEN'),
453 453 UserApiKeys.ROLE_PASSWORD_RESET)
454 454 h.flash(
455 455 _('Given reset token is invalid'), category='error')
456 456 return HTTPFound(self.request.route_path('reset_password'))
457 457
458 458 try:
459 459 owner = token.user
460 460 data = {'email': owner.email, 'token': token.api_key}
461 461 UserModel().reset_password(data)
462 462 h.flash(
463 463 _('Your password reset was successful, '
464 464 'a new password has been sent to your email'),
465 465 category='success')
466 466 except Exception as e:
467 467 log.error(e)
468 468 return HTTPFound(self.request.route_path('reset_password'))
469 469
470 470 return HTTPFound(self.request.route_path('login'))
@@ -1,258 +1,258 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import os
22 22 import re
23 23 import sys
24 24 import logging
25 25 import signal
26 26 import tempfile
27 27 from subprocess import Popen, PIPE
28 28 import urllib.parse
29 29
30 30 from .base import VcsServer
31 31
32 32 log = logging.getLogger(__name__)
33 33
34 34
35 35 class SubversionTunnelWrapper(object):
36 36 process = None
37 37
38 38 def __init__(self, server):
39 39 self.server = server
40 40 self.timeout = 30
41 41 self.stdin = sys.stdin
42 42 self.stdout = sys.stdout
43 43 self.svn_conf_fd, self.svn_conf_path = tempfile.mkstemp()
44 44 self.hooks_env_fd, self.hooks_env_path = tempfile.mkstemp()
45 45
46 46 self.read_only = True # flag that we set to make the hooks readonly
47 47
48 48 def create_svn_config(self):
49 49 content = (
50 50 '[general]\n'
51 51 'hooks-env = {}\n').format(self.hooks_env_path)
52 52 with os.fdopen(self.svn_conf_fd, 'w') as config_file:
53 53 config_file.write(content)
54 54
55 55 def create_hooks_env(self):
56 56 content = (
57 57 '[default]\n'
58 58 'LANG = en_US.UTF-8\n')
59 59 if self.read_only:
60 60 content += 'SSH_READ_ONLY = 1\n'
61 61 with os.fdopen(self.hooks_env_fd, 'w') as hooks_env_file:
62 62 hooks_env_file.write(content)
63 63
64 64 def remove_configs(self):
65 65 os.remove(self.svn_conf_path)
66 66 os.remove(self.hooks_env_path)
67 67
68 68 def command(self):
69 69 root = self.server.get_root_store()
70 70 username = self.server.user.username
71 71
72 72 command = [
73 73 self.server.svn_path, '-t',
74 74 '--config-file', self.svn_conf_path,
75 75 '--tunnel-user', username,
76 76 '-r', root]
77 77 log.debug("Final CMD: %s", ' '.join(command))
78 78 return command
79 79
80 80 def start(self):
81 81 command = self.command()
82 82 self.process = Popen(' '.join(command), stdin=PIPE, shell=True)
83 83
84 84 def sync(self):
85 85 while self.process.poll() is None:
86 86 next_byte = self.stdin.read(1)
87 87 if not next_byte:
88 88 break
89 89 self.process.stdin.write(next_byte)
90 90 self.remove_configs()
91 91
92 92 @property
93 93 def return_code(self):
94 94 return self.process.returncode
95 95
96 96 def get_first_client_response(self):
97 97 signal.signal(signal.SIGALRM, self.interrupt)
98 98 signal.alarm(self.timeout)
99 99 first_response = self._read_first_client_response()
100 100 signal.alarm(0)
101 101 return (self._parse_first_client_response(first_response)
102 102 if first_response else None)
103 103
104 104 def patch_first_client_response(self, response, **kwargs):
105 105 self.create_hooks_env()
106 106 data = response.copy()
107 107 data.update(kwargs)
108 108 data['url'] = self._svn_string(data['url'])
109 109 data['ra_client'] = self._svn_string(data['ra_client'])
110 110 data['client'] = data['client'] or ''
111 111 buffer_ = (
112 112 "( {version} ( {capabilities} ) {url}{ra_client}"
113 113 "( {client}) ) ".format(**data))
114 114 self.process.stdin.write(buffer_)
115 115
116 116 def fail(self, message):
117 117 print("( failure ( ( 210005 {message} 0: 0 ) ) )".format(
118 118 message=self._svn_string(message)))
119 119 self.remove_configs()
120 120 self.process.kill()
121 121 return 1
122 122
123 123 def interrupt(self, signum, frame):
124 124 self.fail("Exited by timeout")
125 125
126 126 def _svn_string(self, str_):
127 127 if not str_:
128 128 return ''
129 129 return '{length}:{string} '.format(length=len(str_), string=str_)
130 130
131 131 def _read_first_client_response(self):
132 132 buffer_ = ""
133 133 brackets_stack = []
134 134 while True:
135 135 next_byte = self.stdin.read(1)
136 136 buffer_ += next_byte
137 137 if next_byte == "(":
138 138 brackets_stack.append(next_byte)
139 139 elif next_byte == ")":
140 140 brackets_stack.pop()
141 141 elif next_byte == " " and not brackets_stack:
142 142 break
143 143
144 144 return buffer_
145 145
146 146 def _parse_first_client_response(self, buffer_):
147 147 """
148 148 According to the Subversion RA protocol, the first request
149 149 should look like:
150 150
151 151 ( version:number ( cap:word ... ) url:string ? ra-client:string
152 152 ( ? client:string ) )
153 153
154 154 Please check https://svn.apache.org/repos/asf/subversion/trunk/subversion/libsvn_ra_svn/protocol
155 155 """
156 156 version_re = r'(?P<version>\d+)'
157 157 capabilities_re = r'\(\s(?P<capabilities>[\w\d\-\ ]+)\s\)'
158 158 url_re = r'\d+\:(?P<url>[\W\w]+)'
159 159 ra_client_re = r'(\d+\:(?P<ra_client>[\W\w]+)\s)'
160 160 client_re = r'(\d+\:(?P<client>[\W\w]+)\s)*'
161 161 regex = re.compile(
162 162 r'^\(\s{version}\s{capabilities}\s{url}\s{ra_client}'
163 163 r'\(\s{client}\)\s\)\s*$'.format(
164 164 version=version_re, capabilities=capabilities_re,
165 165 url=url_re, ra_client=ra_client_re, client=client_re))
166 166 matcher = regex.match(buffer_)
167 167
168 168 return matcher.groupdict() if matcher else None
169 169
170 170 def _match_repo_name(self, url):
171 171 """
172 172 Given an server url, try to match it against ALL known repository names.
173 173 This handles a tricky SVN case for SSH and subdir commits.
174 174 E.g if our repo name is my-svn-repo, a svn commit on file in a subdir would
175 175 result in the url with this subdir added.
176 176 """
177 177 # case 1 direct match, we don't do any "heavy" lookups
178 178 if url in self.server.user_permissions:
179 179 return url
180 180
181 181 log.debug('Extracting repository name from subdir path %s', url)
182 182 # case 2 we check all permissions, and match closes possible case...
183 183 # NOTE(dan): In this case we only know that url has a subdir parts, it's safe
184 184 # to assume that it will have the repo name as prefix, we ensure the prefix
185 185 # for similar repositories isn't matched by adding a /
186 186 # e.g subgroup/repo-name/ and subgroup/repo-name-1/ would work correct.
187 187 for repo_name in self.server.user_permissions:
188 188 repo_name_prefix = repo_name + '/'
189 189 if url.startswith(repo_name_prefix):
190 190 log.debug('Found prefix %s match, returning proper repository name',
191 191 repo_name_prefix)
192 192 return repo_name
193 193
194 194 return
195 195
196 196 def run(self, extras):
197 197 action = 'pull'
198 198 self.create_svn_config()
199 199 self.start()
200 200
201 201 first_response = self.get_first_client_response()
202 202 if not first_response:
203 203 return self.fail("Repository name cannot be extracted")
204 204
205 url_parts = urllib.parse.urlparse.urlparse(first_response['url'])
205 url_parts = urllib.parse.urlparse(first_response['url'])
206 206
207 207 self.server.repo_name = self._match_repo_name(url_parts.path.strip('/'))
208 208
209 209 exit_code = self.server._check_permissions(action)
210 210 if exit_code:
211 211 return exit_code
212 212
213 213 # set the readonly flag to False if we have proper permissions
214 214 if self.server.has_write_perm():
215 215 self.read_only = False
216 216 self.server.update_environment(action=action, extras=extras)
217 217
218 218 self.patch_first_client_response(first_response)
219 219 self.sync()
220 220 return self.return_code
221 221
222 222
223 223 class SubversionServer(VcsServer):
224 224 backend = 'svn'
225 225 repo_user_agent = 'svn'
226 226
227 227 def __init__(self, store, ini_path, repo_name,
228 228 user, user_permissions, config, env):
229 229 super(SubversionServer, self)\
230 230 .__init__(user, user_permissions, config, env)
231 231 self.store = store
232 232 self.ini_path = ini_path
233 233 # NOTE(dan): repo_name at this point is empty,
234 234 # this is set later in .run() based from parsed input stream
235 235 self.repo_name = repo_name
236 236 self._path = self.svn_path = config.get('app:main', 'ssh.executable.svn')
237 237
238 238 self.tunnel = SubversionTunnelWrapper(server=self)
239 239
240 240 def _handle_tunnel(self, extras):
241 241
242 242 # pre-auth
243 243 action = 'pull'
244 244 # Special case for SVN, we extract repo name at later stage
245 245 # exit_code = self._check_permissions(action)
246 246 # if exit_code:
247 247 # return exit_code, False
248 248
249 249 req = self.env['request']
250 250 server_url = req.host_url + req.script_name
251 251 extras['server_url'] = server_url
252 252
253 253 log.debug('Using %s binaries from path %s', self.backend, self._path)
254 254 exit_code = self.tunnel.run(extras)
255 255
256 256 return exit_code, action == "push"
257 257
258 258
@@ -1,580 +1,580 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21
22 22 """
23 23 Renderer for markup languages with ability to parse using rst or markdown
24 24 """
25 25
26 26 import re
27 27 import os
28 28 import lxml
29 29 import logging
30 30 import urllib.parse
31 31 import bleach
32 32
33 33 from mako.lookup import TemplateLookup
34 34 from mako.template import Template as MakoTemplate
35 35
36 36 from docutils.core import publish_parts
37 37 from docutils.parsers.rst import directives
38 38 from docutils import writers
39 39 from docutils.writers import html4css1
40 40 import markdown
41 41
42 42 from rhodecode.lib.markdown_ext import GithubFlavoredMarkdownExtension
43 43 from rhodecode.lib.utils2 import (safe_unicode, md5_safe, MENTIONS_REGEX)
44 44
45 45 log = logging.getLogger(__name__)
46 46
47 47 # default renderer used to generate automated comments
48 48 DEFAULT_COMMENTS_RENDERER = 'rst'
49 49
50 50 try:
51 51 from lxml.html import fromstring
52 52 from lxml.html import tostring
53 53 except ImportError:
54 54 log.exception('Failed to import lxml')
55 55 fromstring = None
56 56 tostring = None
57 57
58 58
59 59 class CustomHTMLTranslator(writers.html4css1.HTMLTranslator):
60 60 """
61 61 Custom HTML Translator used for sandboxing potential
62 62 JS injections in ref links
63 63 """
64 64 def visit_literal_block(self, node):
65 65 self.body.append(self.starttag(node, 'pre', CLASS='codehilite literal-block'))
66 66
67 67 def visit_reference(self, node):
68 68 if 'refuri' in node.attributes:
69 69 refuri = node['refuri']
70 70 if ':' in refuri:
71 71 prefix, link = refuri.lstrip().split(':', 1)
72 72 prefix = prefix or ''
73 73
74 74 if prefix.lower() == 'javascript':
75 75 # we don't allow javascript type of refs...
76 76 node['refuri'] = 'javascript:alert("SandBoxedJavascript")'
77 77
78 78 # old style class requires this...
79 79 return html4css1.HTMLTranslator.visit_reference(self, node)
80 80
81 81
82 82 class RhodeCodeWriter(writers.html4css1.Writer):
83 83 def __init__(self):
84 84 writers.Writer.__init__(self)
85 85 self.translator_class = CustomHTMLTranslator
86 86
87 87
88 88 def relative_links(html_source, server_paths):
89 89 if not html_source:
90 90 return html_source
91 91
92 92 if not fromstring and tostring:
93 93 return html_source
94 94
95 95 try:
96 96 doc = lxml.html.fromstring(html_source)
97 97 except Exception:
98 98 return html_source
99 99
100 100 for el in doc.cssselect('img, video'):
101 101 src = el.attrib.get('src')
102 102 if src:
103 103 el.attrib['src'] = relative_path(src, server_paths['raw'])
104 104
105 105 for el in doc.cssselect('a:not(.gfm)'):
106 106 src = el.attrib.get('href')
107 107 if src:
108 108 raw_mode = el.attrib['href'].endswith('?raw=1')
109 109 if raw_mode:
110 110 el.attrib['href'] = relative_path(src, server_paths['raw'])
111 111 else:
112 112 el.attrib['href'] = relative_path(src, server_paths['standard'])
113 113
114 114 return lxml.html.tostring(doc)
115 115
116 116
117 117 def relative_path(path, request_path, is_repo_file=None):
118 118 """
119 119 relative link support, path is a rel path, and request_path is current
120 120 server path (not absolute)
121 121
122 122 e.g.
123 123
124 124 path = '../logo.png'
125 125 request_path= '/repo/files/path/file.md'
126 126 produces: '/repo/files/logo.png'
127 127 """
128 128 # TODO(marcink): unicode/str support ?
129 129 # maybe=> safe_unicode(urllib.quote(safe_str(final_path), '/:'))
130 130
131 131 def dummy_check(p):
132 132 return True # assume default is a valid file path
133 133
134 134 is_repo_file = is_repo_file or dummy_check
135 135 if not path:
136 136 return request_path
137 137
138 138 path = safe_unicode(path)
139 139 request_path = safe_unicode(request_path)
140 140
141 141 if path.startswith((u'data:', u'javascript:', u'#', u':')):
142 142 # skip data, anchor, invalid links
143 143 return path
144 144
145 is_absolute = bool(urllib.parse.urlparse.urlparse(path).netloc)
145 is_absolute = bool(urllib.parse.urlparse(path).netloc)
146 146 if is_absolute:
147 147 return path
148 148
149 149 if not request_path:
150 150 return path
151 151
152 152 if path.startswith(u'/'):
153 153 path = path[1:]
154 154
155 155 if path.startswith(u'./'):
156 156 path = path[2:]
157 157
158 158 parts = request_path.split('/')
159 159 # compute how deep we need to traverse the request_path
160 160 depth = 0
161 161
162 162 if is_repo_file(request_path):
163 163 # if request path is a VALID file, we use a relative path with
164 164 # one level up
165 165 depth += 1
166 166
167 167 while path.startswith(u'../'):
168 168 depth += 1
169 169 path = path[3:]
170 170
171 171 if depth > 0:
172 172 parts = parts[:-depth]
173 173
174 174 parts.append(path)
175 175 final_path = u'/'.join(parts).lstrip(u'/')
176 176
177 177 return u'/' + final_path
178 178
179 179
180 180 _cached_markdown_renderer = None
181 181
182 182
183 183 def get_markdown_renderer(extensions, output_format):
184 184 global _cached_markdown_renderer
185 185
186 186 if _cached_markdown_renderer is None:
187 187 _cached_markdown_renderer = markdown.Markdown(
188 188 extensions=extensions,
189 189 enable_attributes=False, output_format=output_format)
190 190 return _cached_markdown_renderer
191 191
192 192
193 193 _cached_markdown_renderer_flavored = None
194 194
195 195
196 196 def get_markdown_renderer_flavored(extensions, output_format):
197 197 global _cached_markdown_renderer_flavored
198 198
199 199 if _cached_markdown_renderer_flavored is None:
200 200 _cached_markdown_renderer_flavored = markdown.Markdown(
201 201 extensions=extensions + [GithubFlavoredMarkdownExtension()],
202 202 enable_attributes=False, output_format=output_format)
203 203 return _cached_markdown_renderer_flavored
204 204
205 205
206 206 class MarkupRenderer(object):
207 207 RESTRUCTUREDTEXT_DISALLOWED_DIRECTIVES = ['include', 'meta', 'raw']
208 208
209 209 MARKDOWN_PAT = re.compile(r'\.(md|mkdn?|mdown|markdown)$', re.IGNORECASE)
210 210 RST_PAT = re.compile(r'\.re?st$', re.IGNORECASE)
211 211 JUPYTER_PAT = re.compile(r'\.(ipynb)$', re.IGNORECASE)
212 212 PLAIN_PAT = re.compile(r'^readme$', re.IGNORECASE)
213 213
214 214 URL_PAT = re.compile(r'(http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]'
215 215 r'|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)')
216 216
217 217 MENTION_PAT = re.compile(MENTIONS_REGEX)
218 218
219 219 extensions = ['markdown.extensions.codehilite', 'markdown.extensions.extra',
220 220 'markdown.extensions.def_list', 'markdown.extensions.sane_lists']
221 221
222 222 output_format = 'html4'
223 223
224 224 # extension together with weights. Lower is first means we control how
225 225 # extensions are attached to readme names with those.
226 226 PLAIN_EXTS = [
227 227 # prefer no extension
228 228 ('', 0), # special case that renders READMES names without extension
229 229 ('.text', 2), ('.TEXT', 2),
230 230 ('.txt', 3), ('.TXT', 3)
231 231 ]
232 232
233 233 RST_EXTS = [
234 234 ('.rst', 1), ('.rest', 1),
235 235 ('.RST', 2), ('.REST', 2)
236 236 ]
237 237
238 238 MARKDOWN_EXTS = [
239 239 ('.md', 1), ('.MD', 1),
240 240 ('.mkdn', 2), ('.MKDN', 2),
241 241 ('.mdown', 3), ('.MDOWN', 3),
242 242 ('.markdown', 4), ('.MARKDOWN', 4)
243 243 ]
244 244
245 245 def _detect_renderer(self, source, filename=None):
246 246 """
247 247 runs detection of what renderer should be used for generating html
248 248 from a markup language
249 249
250 250 filename can be also explicitly a renderer name
251 251
252 252 :param source:
253 253 :param filename:
254 254 """
255 255
256 256 if MarkupRenderer.MARKDOWN_PAT.findall(filename):
257 257 detected_renderer = 'markdown'
258 258 elif MarkupRenderer.RST_PAT.findall(filename):
259 259 detected_renderer = 'rst'
260 260 elif MarkupRenderer.JUPYTER_PAT.findall(filename):
261 261 detected_renderer = 'jupyter'
262 262 elif MarkupRenderer.PLAIN_PAT.findall(filename):
263 263 detected_renderer = 'plain'
264 264 else:
265 265 detected_renderer = 'plain'
266 266
267 267 return getattr(MarkupRenderer, detected_renderer)
268 268
269 269 @classmethod
270 270 def bleach_clean(cls, text):
271 271 from .bleach_whitelist import markdown_attrs, markdown_tags
272 272 allowed_tags = markdown_tags
273 273 allowed_attrs = markdown_attrs
274 274
275 275 try:
276 276 return bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs)
277 277 except Exception:
278 278 return 'UNPARSEABLE TEXT'
279 279
280 280 @classmethod
281 281 def renderer_from_filename(cls, filename, exclude):
282 282 """
283 283 Detect renderer markdown/rst from filename and optionally use exclude
284 284 list to remove some options. This is mostly used in helpers.
285 285 Returns None when no renderer can be detected.
286 286 """
287 287 def _filter(elements):
288 288 if isinstance(exclude, (list, tuple)):
289 289 return [x for x in elements if x not in exclude]
290 290 return elements
291 291
292 292 if filename.endswith(
293 293 tuple(_filter([x[0] for x in cls.MARKDOWN_EXTS if x[0]]))):
294 294 return 'markdown'
295 295 if filename.endswith(tuple(_filter([x[0] for x in cls.RST_EXTS if x[0]]))):
296 296 return 'rst'
297 297
298 298 return None
299 299
300 300 def render(self, source, filename=None):
301 301 """
302 302 Renders a given filename using detected renderer
303 303 it detects renderers based on file extension or mimetype.
304 304 At last it will just do a simple html replacing new lines with <br/>
305 305
306 306 :param file_name:
307 307 :param source:
308 308 """
309 309
310 310 renderer = self._detect_renderer(source, filename)
311 311 readme_data = renderer(source)
312 312 return readme_data
313 313
314 314 @classmethod
315 315 def _flavored_markdown(cls, text):
316 316 """
317 317 Github style flavored markdown
318 318
319 319 :param text:
320 320 """
321 321
322 322 # Extract pre blocks.
323 323 extractions = {}
324 324
325 325 def pre_extraction_callback(matchobj):
326 326 digest = md5_safe(matchobj.group(0))
327 327 extractions[digest] = matchobj.group(0)
328 328 return "{gfm-extraction-%s}" % digest
329 329 pattern = re.compile(r'<pre>.*?</pre>', re.MULTILINE | re.DOTALL)
330 330 text = re.sub(pattern, pre_extraction_callback, text)
331 331
332 332 # Prevent foo_bar_baz from ending up with an italic word in the middle.
333 333 def italic_callback(matchobj):
334 334 s = matchobj.group(0)
335 335 if list(s).count('_') >= 2:
336 336 return s.replace('_', r'\_')
337 337 return s
338 338 text = re.sub(r'^(?! {4}|\t)\w+_\w+_\w[\w_]*', italic_callback, text)
339 339
340 340 # Insert pre block extractions.
341 341 def pre_insert_callback(matchobj):
342 342 return '\n\n' + extractions[matchobj.group(1)]
343 343 text = re.sub(r'\{gfm-extraction-([0-9a-f]{32})\}',
344 344 pre_insert_callback, text)
345 345
346 346 return text
347 347
348 348 @classmethod
349 349 def urlify_text(cls, text):
350 350 def url_func(match_obj):
351 351 url_full = match_obj.groups()[0]
352 352 return '<a href="%(url)s">%(url)s</a>' % ({'url': url_full})
353 353
354 354 return cls.URL_PAT.sub(url_func, text)
355 355
356 356 @classmethod
357 357 def convert_mentions(cls, text, mode):
358 358 mention_pat = cls.MENTION_PAT
359 359
360 360 def wrapp(match_obj):
361 361 uname = match_obj.groups()[0]
362 362 hovercard_url = "pyroutes.url('hovercard_username', {'username': '%s'});" % uname
363 363
364 364 if mode == 'markdown':
365 365 tmpl = '<strong class="tooltip-hovercard" data-hovercard-alt="{uname}" data-hovercard-url="{hovercard_url}">@{uname}</strong>'
366 366 elif mode == 'rst':
367 367 tmpl = ' **@{uname}** '
368 368 else:
369 369 raise ValueError('mode must be rst or markdown')
370 370
371 371 return tmpl.format(**{'uname': uname,
372 372 'hovercard_url': hovercard_url})
373 373
374 374 return mention_pat.sub(wrapp, text).strip()
375 375
376 376 @classmethod
377 377 def plain(cls, source, universal_newline=True, leading_newline=True):
378 378 source = safe_unicode(source)
379 379 if universal_newline:
380 380 newline = '\n'
381 381 source = newline.join(source.splitlines())
382 382
383 383 rendered_source = cls.urlify_text(source)
384 384 source = ''
385 385 if leading_newline:
386 386 source += '<br />'
387 387 source += rendered_source.replace("\n", '<br />')
388 388
389 389 rendered = cls.bleach_clean(source)
390 390 return rendered
391 391
392 392 @classmethod
393 393 def markdown(cls, source, safe=True, flavored=True, mentions=False,
394 394 clean_html=True):
395 395 """
396 396 returns markdown rendered code cleaned by the bleach library
397 397 """
398 398
399 399 if flavored:
400 400 markdown_renderer = get_markdown_renderer_flavored(
401 401 cls.extensions, cls.output_format)
402 402 else:
403 403 markdown_renderer = get_markdown_renderer(
404 404 cls.extensions, cls.output_format)
405 405
406 406 if mentions:
407 407 mention_hl = cls.convert_mentions(source, mode='markdown')
408 408 # we extracted mentions render with this using Mentions false
409 409 return cls.markdown(mention_hl, safe=safe, flavored=flavored,
410 410 mentions=False)
411 411
412 412 source = safe_unicode(source)
413 413
414 414 try:
415 415 if flavored:
416 416 source = cls._flavored_markdown(source)
417 417 rendered = markdown_renderer.convert(source)
418 418 except Exception:
419 419 log.exception('Error when rendering Markdown')
420 420 if safe:
421 421 log.debug('Fallback to render in plain mode')
422 422 rendered = cls.plain(source)
423 423 else:
424 424 raise
425 425
426 426 if clean_html:
427 427 rendered = cls.bleach_clean(rendered)
428 428 return rendered
429 429
430 430 @classmethod
431 431 def rst(cls, source, safe=True, mentions=False, clean_html=False):
432 432 if mentions:
433 433 mention_hl = cls.convert_mentions(source, mode='rst')
434 434 # we extracted mentions render with this using Mentions false
435 435 return cls.rst(mention_hl, safe=safe, mentions=False)
436 436
437 437 source = safe_unicode(source)
438 438 try:
439 439 docutils_settings = dict(
440 440 [(alias, None) for alias in
441 441 cls.RESTRUCTUREDTEXT_DISALLOWED_DIRECTIVES])
442 442
443 443 docutils_settings.update({
444 444 'input_encoding': 'unicode',
445 445 'report_level': 4,
446 446 'syntax_highlight': 'short',
447 447 })
448 448
449 449 for k, v in docutils_settings.items():
450 450 directives.register_directive(k, v)
451 451
452 452 parts = publish_parts(source=source,
453 453 writer=RhodeCodeWriter(),
454 454 settings_overrides=docutils_settings)
455 455 rendered = parts["fragment"]
456 456 if clean_html:
457 457 rendered = cls.bleach_clean(rendered)
458 458 return parts['html_title'] + rendered
459 459 except Exception:
460 460 log.exception('Error when rendering RST')
461 461 if safe:
462 462 log.debug('Fallback to render in plain mode')
463 463 return cls.plain(source)
464 464 else:
465 465 raise
466 466
467 467 @classmethod
468 468 def jupyter(cls, source, safe=True):
469 469 from rhodecode.lib import helpers
470 470
471 471 from traitlets.config import Config
472 472 import nbformat
473 473 from nbconvert import HTMLExporter
474 474 from nbconvert.preprocessors import Preprocessor
475 475
476 476 class CustomHTMLExporter(HTMLExporter):
477 477 def _template_file_default(self):
478 478 return 'basic'
479 479
480 480 class Sandbox(Preprocessor):
481 481
482 482 def preprocess(self, nb, resources):
483 483 sandbox_text = 'SandBoxed(IPython.core.display.Javascript object)'
484 484 for cell in nb['cells']:
485 485 if not safe:
486 486 continue
487 487
488 488 if 'outputs' in cell:
489 489 for cell_output in cell['outputs']:
490 490 if 'data' in cell_output:
491 491 if 'application/javascript' in cell_output['data']:
492 492 cell_output['data']['text/plain'] = sandbox_text
493 493 cell_output['data'].pop('application/javascript', None)
494 494
495 495 if 'source' in cell and cell['cell_type'] == 'markdown':
496 496 # sanitize similar like in markdown
497 497 cell['source'] = cls.bleach_clean(cell['source'])
498 498
499 499 return nb, resources
500 500
501 501 def _sanitize_resources(input_resources):
502 502 """
503 503 Skip/sanitize some of the CSS generated and included in jupyter
504 504 so it doesn't messes up UI so much
505 505 """
506 506
507 507 # TODO(marcink): probably we should replace this with whole custom
508 508 # CSS set that doesn't screw up, but jupyter generated html has some
509 509 # special markers, so it requires Custom HTML exporter template with
510 510 # _default_template_path_default, to achieve that
511 511
512 512 # strip the reset CSS
513 513 input_resources[0] = input_resources[0][input_resources[0].find('/*! Source'):]
514 514 return input_resources
515 515
516 516 def as_html(notebook):
517 517 conf = Config()
518 518 conf.CustomHTMLExporter.preprocessors = [Sandbox]
519 519 html_exporter = CustomHTMLExporter(config=conf)
520 520
521 521 (body, resources) = html_exporter.from_notebook_node(notebook)
522 522 header = '<!-- ## IPYTHON NOTEBOOK RENDERING ## -->'
523 523 js = MakoTemplate(r'''
524 524 <!-- MathJax configuration -->
525 525 <script type="text/x-mathjax-config">
526 526 MathJax.Hub.Config({
527 527 jax: ["input/TeX","output/HTML-CSS", "output/PreviewHTML"],
528 528 extensions: ["tex2jax.js","MathMenu.js","MathZoom.js", "fast-preview.js", "AssistiveMML.js", "[Contrib]/a11y/accessibility-menu.js"],
529 529 TeX: {
530 530 extensions: ["AMSmath.js","AMSsymbols.js","noErrors.js","noUndefined.js"]
531 531 },
532 532 tex2jax: {
533 533 inlineMath: [ ['$','$'], ["\\(","\\)"] ],
534 534 displayMath: [ ['$$','$$'], ["\\[","\\]"] ],
535 535 processEscapes: true,
536 536 processEnvironments: true
537 537 },
538 538 // Center justify equations in code and markdown cells. Elsewhere
539 539 // we use CSS to left justify single line equations in code cells.
540 540 displayAlign: 'center',
541 541 "HTML-CSS": {
542 542 styles: {'.MathJax_Display': {"margin": 0}},
543 543 linebreaks: { automatic: true },
544 544 availableFonts: ["STIX", "TeX"]
545 545 },
546 546 showMathMenu: false
547 547 });
548 548 </script>
549 549 <!-- End of MathJax configuration -->
550 550 <script src="${h.asset('js/src/math_jax/MathJax.js')}"></script>
551 551 ''').render(h=helpers)
552 552
553 553 css = MakoTemplate(r'''
554 554 <link rel="stylesheet" type="text/css" href="${h.asset('css/style-ipython.css', ver=ver)}" media="screen"/>
555 555 ''').render(h=helpers, ver='ver1')
556 556
557 557 body = '\n'.join([header, css, js, body])
558 558 return body, resources
559 559
560 560 notebook = nbformat.reads(source, as_version=4)
561 561 (body, resources) = as_html(notebook)
562 562 return body
563 563
564 564
565 565 class RstTemplateRenderer(object):
566 566
567 567 def __init__(self):
568 568 base = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
569 569 rst_template_dirs = [os.path.join(base, 'templates', 'rst_templates')]
570 570 self.template_store = TemplateLookup(
571 571 directories=rst_template_dirs,
572 572 input_encoding='utf-8',
573 573 imports=['from rhodecode.lib import helpers as h'])
574 574
575 575 def _get_template(self, templatename):
576 576 return self.template_store.get_template(templatename)
577 577
578 578 def render(self, template_name, **kwargs):
579 579 template = self._get_template(template_name)
580 580 return template.render(**kwargs)
@@ -1,229 +1,229 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import base64
22 22 import logging
23 23 import urllib.request, urllib.parse, urllib.error
24 24 import urllib.parse
25 25
26 26 import requests
27 27 from pyramid.httpexceptions import HTTPNotAcceptable
28 28
29 29 from rhodecode.lib import rc_cache
30 30 from rhodecode.lib.middleware import simplevcs
31 31 from rhodecode.lib.utils import is_valid_repo
32 32 from rhodecode.lib.utils2 import str2bool, safe_int, safe_str
33 33 from rhodecode.lib.ext_json import json
34 34 from rhodecode.lib.hooks_daemon import store_txn_id_data
35 35
36 36
37 37 log = logging.getLogger(__name__)
38 38
39 39
40 40 class SimpleSvnApp(object):
41 41 IGNORED_HEADERS = [
42 42 'connection', 'keep-alive', 'content-encoding',
43 43 'transfer-encoding', 'content-length']
44 44 rc_extras = {}
45 45
46 46 def __init__(self, config):
47 47 self.config = config
48 48
49 49 def __call__(self, environ, start_response):
50 50 request_headers = self._get_request_headers(environ)
51 51 data = environ['wsgi.input']
52 52 req_method = environ['REQUEST_METHOD']
53 53 has_content_length = 'CONTENT_LENGTH' in environ
54 54 path_info = self._get_url(
55 55 self.config.get('subversion_http_server_url', ''), environ['PATH_INFO'])
56 56 transfer_encoding = environ.get('HTTP_TRANSFER_ENCODING', '')
57 57 log.debug('Handling: %s method via `%s`', req_method, path_info)
58 58
59 59 # stream control flag, based on request and content type...
60 60 stream = False
61 61
62 62 if req_method in ['MKCOL'] or has_content_length:
63 63 data_processed = False
64 64 # read chunk to check if we have txn-with-props
65 65 initial_data = data.read(1024)
66 66 if initial_data.startswith('(create-txn-with-props'):
67 67 data = initial_data + data.read()
68 68 # store on-the-fly our rc_extra using svn revision properties
69 69 # those can be read later on in hooks executed so we have a way
70 70 # to pass in the data into svn hooks
71 71 rc_data = base64.urlsafe_b64encode(json.dumps(self.rc_extras))
72 72 rc_data_len = len(rc_data)
73 73 # header defines data length, and serialized data
74 74 skel = ' rc-scm-extras {} {}'.format(rc_data_len, rc_data)
75 75 data = data[:-2] + skel + '))'
76 76 data_processed = True
77 77
78 78 if not data_processed:
79 79 # NOTE(johbo): Avoid that we end up with sending the request in chunked
80 80 # transfer encoding (mainly on Gunicorn). If we know the content
81 81 # length, then we should transfer the payload in one request.
82 82 data = initial_data + data.read()
83 83
84 84 if req_method in ['GET', 'PUT'] or transfer_encoding == 'chunked':
85 85 # NOTE(marcink): when getting/uploading files we want to STREAM content
86 86 # back to the client/proxy instead of buffering it here...
87 87 stream = True
88 88
89 89 stream = stream
90 90 log.debug('Calling SVN PROXY at `%s`, using method:%s. Stream: %s',
91 91 path_info, req_method, stream)
92 92 try:
93 93 response = requests.request(
94 94 req_method, path_info,
95 95 data=data, headers=request_headers, stream=stream)
96 96 except requests.ConnectionError:
97 97 log.exception('ConnectionError occurred for endpoint %s', path_info)
98 98 raise
99 99
100 100 if response.status_code not in [200, 401]:
101 101 from rhodecode.lib.utils2 import safe_str
102 102 text = '\n{}'.format(safe_str(response.text)) if response.text else ''
103 103 if response.status_code >= 500:
104 104 log.error('Got SVN response:%s with text:`%s`', response, text)
105 105 else:
106 106 log.debug('Got SVN response:%s with text:`%s`', response, text)
107 107 else:
108 108 log.debug('got response code: %s', response.status_code)
109 109
110 110 response_headers = self._get_response_headers(response.headers)
111 111
112 112 if response.headers.get('SVN-Txn-name'):
113 113 svn_tx_id = response.headers.get('SVN-Txn-name')
114 114 txn_id = rc_cache.utils.compute_key_from_params(
115 115 self.config['repository'], svn_tx_id)
116 116 port = safe_int(self.rc_extras['hooks_uri'].split(':')[-1])
117 117 store_txn_id_data(txn_id, {'port': port})
118 118
119 119 start_response(
120 120 '{} {}'.format(response.status_code, response.reason),
121 121 response_headers)
122 122 return response.iter_content(chunk_size=1024)
123 123
124 124 def _get_url(self, svn_http_server, path):
125 125 svn_http_server_url = (svn_http_server or '').rstrip('/')
126 url_path = urllib.parse.urlparse.urljoin(svn_http_server_url + '/', (path or '').lstrip('/'))
126 url_path = urllib.parse.urljoin(svn_http_server_url + '/', (path or '').lstrip('/'))
127 127 url_path = urllib.parse.quote(url_path, safe="/:=~+!$,;'")
128 128 return url_path
129 129
130 130 def _get_request_headers(self, environ):
131 131 headers = {}
132 132
133 133 for key in environ:
134 134 if not key.startswith('HTTP_'):
135 135 continue
136 136 new_key = key.split('_')
137 137 new_key = [k.capitalize() for k in new_key[1:]]
138 138 new_key = '-'.join(new_key)
139 139 headers[new_key] = environ[key]
140 140
141 141 if 'CONTENT_TYPE' in environ:
142 142 headers['Content-Type'] = environ['CONTENT_TYPE']
143 143
144 144 if 'CONTENT_LENGTH' in environ:
145 145 headers['Content-Length'] = environ['CONTENT_LENGTH']
146 146
147 147 return headers
148 148
149 149 def _get_response_headers(self, headers):
150 150 headers = [
151 151 (h, headers[h])
152 152 for h in headers
153 153 if h.lower() not in self.IGNORED_HEADERS
154 154 ]
155 155
156 156 return headers
157 157
158 158
159 159 class DisabledSimpleSvnApp(object):
160 160 def __init__(self, config):
161 161 self.config = config
162 162
163 163 def __call__(self, environ, start_response):
164 164 reason = 'Cannot handle SVN call because: SVN HTTP Proxy is not enabled'
165 165 log.warning(reason)
166 166 return HTTPNotAcceptable(reason)(environ, start_response)
167 167
168 168
169 169 class SimpleSvn(simplevcs.SimpleVCS):
170 170
171 171 SCM = 'svn'
172 172 READ_ONLY_COMMANDS = ('OPTIONS', 'PROPFIND', 'GET', 'REPORT')
173 173 DEFAULT_HTTP_SERVER = 'http://localhost:8090'
174 174
175 175 def _get_repository_name(self, environ):
176 176 """
177 177 Gets repository name out of PATH_INFO header
178 178
179 179 :param environ: environ where PATH_INFO is stored
180 180 """
181 181 path = environ['PATH_INFO'].split('!')
182 182 repo_name = path[0].strip('/')
183 183
184 184 # SVN includes the whole path in it's requests, including
185 185 # subdirectories inside the repo. Therefore we have to search for
186 186 # the repo root directory.
187 187 if not is_valid_repo(
188 188 repo_name, self.base_path, explicit_scm=self.SCM):
189 189 current_path = ''
190 190 for component in repo_name.split('/'):
191 191 current_path += component
192 192 if is_valid_repo(
193 193 current_path, self.base_path, explicit_scm=self.SCM):
194 194 return current_path
195 195 current_path += '/'
196 196
197 197 return repo_name
198 198
199 199 def _get_action(self, environ):
200 200 return (
201 201 'pull'
202 202 if environ['REQUEST_METHOD'] in self.READ_ONLY_COMMANDS
203 203 else 'push')
204 204
205 205 def _should_use_callback_daemon(self, extras, environ, action):
206 206 # only MERGE command triggers hooks, so we don't want to start
207 207 # hooks server too many times. POST however starts the svn transaction
208 208 # so we also need to run the init of callback daemon of POST
209 209 if environ['REQUEST_METHOD'] in ['MERGE', 'POST']:
210 210 return True
211 211 return False
212 212
213 213 def _create_wsgi_app(self, repo_path, repo_name, config):
214 214 if self._is_svn_enabled():
215 215 return SimpleSvnApp(config)
216 216 # we don't have http proxy enabled return dummy request handler
217 217 return DisabledSimpleSvnApp(config)
218 218
219 219 def _is_svn_enabled(self):
220 220 conf = self.repo_vcs_config
221 221 return str2bool(conf.get('vcs_svn_proxy', 'http_requests_enabled'))
222 222
223 223 def _create_config(self, extras, repo_name, scheme='http'):
224 224 conf = self.repo_vcs_config
225 225 server_url = conf.get('vcs_svn_proxy', 'http_server_url')
226 226 server_url = server_url or self.DEFAULT_HTTP_SERVER
227 227
228 228 extras['subversion_http_server_url'] = server_url
229 229 return extras
@@ -1,189 +1,189 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 Implementation of the scm_app interface using raw HTTP communication.
23 23 """
24 24
25 25 import base64
26 26 import logging
27 27 import urllib.parse
28 28 import wsgiref.util
29 29
30 30 import msgpack
31 31 import requests
32 32 import webob.request
33 33
34 34 import rhodecode
35 35
36 36
37 37 log = logging.getLogger(__name__)
38 38
39 39
40 40 def create_git_wsgi_app(repo_path, repo_name, config):
41 41 url = _vcs_streaming_url() + 'git/'
42 42 return VcsHttpProxy(url, repo_path, repo_name, config)
43 43
44 44
45 45 def create_hg_wsgi_app(repo_path, repo_name, config):
46 46 url = _vcs_streaming_url() + 'hg/'
47 47 return VcsHttpProxy(url, repo_path, repo_name, config)
48 48
49 49
50 50 def _vcs_streaming_url():
51 51 template = 'http://{}/stream/'
52 52 return template.format(rhodecode.CONFIG['vcs.server'])
53 53
54 54
55 55 # TODO: johbo: Avoid the global.
56 56 session = requests.Session()
57 57 # Requests speedup, avoid reading .netrc and similar
58 58 session.trust_env = False
59 59
60 60 # prevent urllib3 spawning our logs.
61 61 logging.getLogger("requests.packages.urllib3.connectionpool").setLevel(
62 62 logging.WARNING)
63 63
64 64
65 65 class VcsHttpProxy(object):
66 66 """
67 67 A WSGI application which proxies vcs requests.
68 68
69 69 The goal is to shuffle the data around without touching it. The only
70 70 exception is the extra data from the config object which we send to the
71 71 server as well.
72 72 """
73 73
74 74 def __init__(self, url, repo_path, repo_name, config):
75 75 """
76 76 :param str url: The URL of the VCSServer to call.
77 77 """
78 78 self._url = url
79 79 self._repo_name = repo_name
80 80 self._repo_path = repo_path
81 81 self._config = config
82 82 self.rc_extras = {}
83 83 log.debug(
84 84 "Creating VcsHttpProxy for repo %s, url %s",
85 85 repo_name, url)
86 86
87 87 def __call__(self, environ, start_response):
88 88 config = msgpack.packb(self._config)
89 89 request = webob.request.Request(environ)
90 90 request_headers = request.headers
91 91
92 92 request_headers.update({
93 93 # TODO: johbo: Remove this, rely on URL path only
94 94 'X-RC-Repo-Name': self._repo_name,
95 95 'X-RC-Repo-Path': self._repo_path,
96 96 'X-RC-Path-Info': environ['PATH_INFO'],
97 97
98 98 'X-RC-Repo-Store': self.rc_extras.get('repo_store'),
99 99 'X-RC-Server-Config-File': self.rc_extras.get('config'),
100 100
101 101 'X-RC-Auth-User': self.rc_extras.get('username'),
102 102 'X-RC-Auth-User-Id': str(self.rc_extras.get('user_id')),
103 103 'X-RC-Auth-User-Ip': self.rc_extras.get('ip'),
104 104
105 105 # TODO: johbo: Avoid encoding and put this into payload?
106 106 'X-RC-Repo-Config': base64.b64encode(config),
107 107 'X-RC-Locked-Status-Code': rhodecode.CONFIG.get('lock_ret_code'),
108 108 })
109 109
110 110 method = environ['REQUEST_METHOD']
111 111
112 112 # Preserve the query string
113 113 url = self._url
114 url = urllib.parse.urlparse.urljoin(url, self._repo_name)
114 url = urllib.parse.urljoin(url, self._repo_name)
115 115 if environ.get('QUERY_STRING'):
116 116 url += '?' + environ['QUERY_STRING']
117 117
118 118 log.debug('http-app: preparing request to: %s', url)
119 119 response = session.request(
120 120 method,
121 121 url,
122 122 data=_maybe_stream_request(environ),
123 123 headers=request_headers,
124 124 stream=True)
125 125
126 126 log.debug('http-app: got vcsserver response: %s', response)
127 127 if response.status_code >= 500:
128 128 log.error('Exception returned by vcsserver at: %s %s, %s',
129 129 url, response.status_code, response.content)
130 130
131 131 # Preserve the headers of the response, except hop_by_hop ones
132 132 response_headers = [
133 133 (h, v) for h, v in response.headers.items()
134 134 if not wsgiref.util.is_hop_by_hop(h)
135 135 ]
136 136
137 137 # Build status argument for start_response callable.
138 138 status = '{status_code} {reason_phrase}'.format(
139 139 status_code=response.status_code,
140 140 reason_phrase=response.reason)
141 141
142 142 start_response(status, response_headers)
143 143 return _maybe_stream_response(response)
144 144
145 145
146 146 def read_in_chunks(stream_obj, block_size=1024, chunks=-1):
147 147 """
148 148 Read Stream in chunks, default chunk size: 1k.
149 149 """
150 150 while chunks:
151 151 data = stream_obj.read(block_size)
152 152 if not data:
153 153 break
154 154 yield data
155 155 chunks -= 1
156 156
157 157
158 158 def _is_request_chunked(environ):
159 159 stream = environ.get('HTTP_TRANSFER_ENCODING', '') == 'chunked'
160 160 return stream
161 161
162 162
163 163 def _maybe_stream_request(environ):
164 164 path = environ['PATH_INFO']
165 165 stream = _is_request_chunked(environ)
166 166 log.debug('handling request `%s` with stream support: %s', path, stream)
167 167
168 168 if stream:
169 169 # set stream by 256k
170 170 return read_in_chunks(environ['wsgi.input'], block_size=1024 * 256)
171 171 else:
172 172 return environ['wsgi.input'].read()
173 173
174 174
175 175 def _maybe_stream_response(response):
176 176 """
177 177 Try to generate chunks from the response if it is chunked.
178 178 """
179 179 stream = _is_chunked(response)
180 180 log.debug('returning response with stream: %s', stream)
181 181 if stream:
182 182 # read in 256k Chunks
183 183 return response.raw.read_chunked(amt=1024 * 256)
184 184 else:
185 185 return [response.content]
186 186
187 187
188 188 def _is_chunked(response):
189 189 return response.headers.get('Transfer-Encoding', '') == 'chunked'
@@ -1,412 +1,412 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 Client for the VCSServer implemented based on HTTP.
23 23 """
24 24
25 25 import copy
26 26 import logging
27 27 import threading
28 28 import time
29 29 import urllib.request, urllib.error, urllib.parse
30 30 import urllib.parse
31 31 import uuid
32 32 import traceback
33 33
34 34 import pycurl
35 35 import msgpack
36 36 import requests
37 37 from requests.packages.urllib3.util.retry import Retry
38 38
39 39 import rhodecode
40 40 from rhodecode.lib import rc_cache
41 41 from rhodecode.lib.rc_cache.utils import compute_key_from_params
42 42 from rhodecode.lib.system_info import get_cert_path
43 43 from rhodecode.lib.vcs import exceptions, CurlSession
44 44 from rhodecode.lib.utils2 import str2bool
45 45
46 46 log = logging.getLogger(__name__)
47 47
48 48
49 49 # TODO: mikhail: Keep it in sync with vcsserver's
50 50 # HTTPApplication.ALLOWED_EXCEPTIONS
51 51 EXCEPTIONS_MAP = {
52 52 'KeyError': KeyError,
53 53 'URLError': urllib.error.URLError,
54 54 }
55 55
56 56
57 57 def _remote_call(url, payload, exceptions_map, session):
58 58 try:
59 59 headers = {
60 60 'X-RC-Method': payload.get('method'),
61 61 'X-RC-Repo-Name': payload.get('_repo_name')
62 62 }
63 63 response = session.post(url, data=msgpack.packb(payload), headers=headers)
64 64 except pycurl.error as e:
65 65 msg = '{}. \npycurl traceback: {}'.format(e, traceback.format_exc())
66 66 raise exceptions.HttpVCSCommunicationError(msg)
67 67 except Exception as e:
68 68 message = getattr(e, 'message', '')
69 69 if 'Failed to connect' in message:
70 70 # gevent doesn't return proper pycurl errors
71 71 raise exceptions.HttpVCSCommunicationError(e)
72 72 else:
73 73 raise
74 74
75 75 if response.status_code >= 400:
76 76 log.error('Call to %s returned non 200 HTTP code: %s',
77 77 url, response.status_code)
78 78 raise exceptions.HttpVCSCommunicationError(repr(response.content))
79 79
80 80 try:
81 81 response = msgpack.unpackb(response.content)
82 82 except Exception:
83 83 log.exception('Failed to decode response %r', response.content)
84 84 raise
85 85
86 86 error = response.get('error')
87 87 if error:
88 88 type_ = error.get('type', 'Exception')
89 89 exc = exceptions_map.get(type_, Exception)
90 90 exc = exc(error.get('message'))
91 91 try:
92 92 exc._vcs_kind = error['_vcs_kind']
93 93 except KeyError:
94 94 pass
95 95
96 96 try:
97 97 exc._vcs_server_traceback = error['traceback']
98 98 exc._vcs_server_org_exc_name = error['org_exc']
99 99 exc._vcs_server_org_exc_tb = error['org_exc_tb']
100 100 except KeyError:
101 101 pass
102 102
103 103 raise exc
104 104 return response.get('result')
105 105
106 106
107 107 def _streaming_remote_call(url, payload, exceptions_map, session, chunk_size):
108 108 try:
109 109 headers = {
110 110 'X-RC-Method': payload.get('method'),
111 111 'X-RC-Repo-Name': payload.get('_repo_name')
112 112 }
113 113 response = session.post(url, data=msgpack.packb(payload), headers=headers)
114 114 except pycurl.error as e:
115 115 msg = '{}. \npycurl traceback: {}'.format(e, traceback.format_exc())
116 116 raise exceptions.HttpVCSCommunicationError(msg)
117 117 except Exception as e:
118 118 message = getattr(e, 'message', '')
119 119 if 'Failed to connect' in message:
120 120 # gevent doesn't return proper pycurl errors
121 121 raise exceptions.HttpVCSCommunicationError(e)
122 122 else:
123 123 raise
124 124
125 125 if response.status_code >= 400:
126 126 log.error('Call to %s returned non 200 HTTP code: %s',
127 127 url, response.status_code)
128 128 raise exceptions.HttpVCSCommunicationError(repr(response.content))
129 129
130 130 return response.iter_content(chunk_size=chunk_size)
131 131
132 132
133 133 class ServiceConnection(object):
134 134 def __init__(self, server_and_port, backend_endpoint, session_factory):
135 self.url = urllib.parse.urlparse.urljoin('http://%s' % server_and_port, backend_endpoint)
135 self.url = urllib.parse.urljoin('http://%s' % server_and_port, backend_endpoint)
136 136 self._session_factory = session_factory
137 137
138 138 def __getattr__(self, name):
139 139 def f(*args, **kwargs):
140 140 return self._call(name, *args, **kwargs)
141 141 return f
142 142
143 143 @exceptions.map_vcs_exceptions
144 144 def _call(self, name, *args, **kwargs):
145 145 payload = {
146 146 'id': str(uuid.uuid4()),
147 147 'method': name,
148 148 'params': {'args': args, 'kwargs': kwargs}
149 149 }
150 150 return _remote_call(
151 151 self.url, payload, EXCEPTIONS_MAP, self._session_factory())
152 152
153 153
154 154 class RemoteVCSMaker(object):
155 155
156 156 def __init__(self, server_and_port, backend_endpoint, backend_type, session_factory):
157 self.url = urllib.parse.urlparse.urljoin('http://%s' % server_and_port, backend_endpoint)
158 self.stream_url = urllib.parse.urlparse.urljoin('http://%s' % server_and_port, backend_endpoint+'/stream')
157 self.url = urllib.parse.urljoin('http://%s' % server_and_port, backend_endpoint)
158 self.stream_url = urllib.parse.urljoin('http://%s' % server_and_port, backend_endpoint+'/stream')
159 159
160 160 self._session_factory = session_factory
161 161 self.backend_type = backend_type
162 162
163 163 @classmethod
164 164 def init_cache_region(cls, repo_id):
165 165 cache_namespace_uid = 'cache_repo.{}'.format(repo_id)
166 166 region = rc_cache.get_or_create_region('cache_repo', cache_namespace_uid)
167 167 return region, cache_namespace_uid
168 168
169 169 def __call__(self, path, repo_id, config, with_wire=None):
170 170 log.debug('%s RepoMaker call on %s', self.backend_type.upper(), path)
171 171 return RemoteRepo(path, repo_id, config, self, with_wire=with_wire)
172 172
173 173 def __getattr__(self, name):
174 174 def remote_attr(*args, **kwargs):
175 175 return self._call(name, *args, **kwargs)
176 176 return remote_attr
177 177
178 178 @exceptions.map_vcs_exceptions
179 179 def _call(self, func_name, *args, **kwargs):
180 180 payload = {
181 181 'id': str(uuid.uuid4()),
182 182 'method': func_name,
183 183 'backend': self.backend_type,
184 184 'params': {'args': args, 'kwargs': kwargs}
185 185 }
186 186 url = self.url
187 187 return _remote_call(url, payload, EXCEPTIONS_MAP, self._session_factory())
188 188
189 189
190 190 class RemoteRepo(object):
191 191 CHUNK_SIZE = 16384
192 192
193 193 def __init__(self, path, repo_id, config, remote_maker, with_wire=None):
194 194 self.url = remote_maker.url
195 195 self.stream_url = remote_maker.stream_url
196 196 self._session = remote_maker._session_factory()
197 197
198 198 cache_repo_id = self._repo_id_sanitizer(repo_id)
199 199 _repo_name = self._get_repo_name(config, path)
200 200 self._cache_region, self._cache_namespace = \
201 201 remote_maker.init_cache_region(cache_repo_id)
202 202
203 203 with_wire = with_wire or {}
204 204
205 205 repo_state_uid = with_wire.get('repo_state_uid') or 'state'
206 206
207 207 self._wire = {
208 208 "_repo_name": _repo_name,
209 209 "path": path, # repo path
210 210 "repo_id": repo_id,
211 211 "cache_repo_id": cache_repo_id,
212 212 "config": config,
213 213 "repo_state_uid": repo_state_uid,
214 214 "context": self._create_vcs_cache_context(path, repo_state_uid)
215 215 }
216 216
217 217 if with_wire:
218 218 self._wire.update(with_wire)
219 219
220 220 # NOTE(johbo): Trading complexity for performance. Avoiding the call to
221 221 # log.debug brings a few percent gain even if is is not active.
222 222 if log.isEnabledFor(logging.DEBUG):
223 223 self._call_with_logging = True
224 224
225 225 self.cert_dir = get_cert_path(rhodecode.CONFIG.get('__file__'))
226 226
227 227 def _get_repo_name(self, config, path):
228 228 repo_store = config.get('paths', '/')
229 229 return path.split(repo_store)[-1].lstrip('/')
230 230
231 231 def _repo_id_sanitizer(self, repo_id):
232 232 pathless = repo_id.replace('/', '__').replace('-', '_')
233 233 return ''.join(char if ord(char) < 128 else '_{}_'.format(ord(char)) for char in pathless)
234 234
235 235 def __getattr__(self, name):
236 236
237 237 if name.startswith('stream:'):
238 238 def repo_remote_attr(*args, **kwargs):
239 239 return self._call_stream(name, *args, **kwargs)
240 240 else:
241 241 def repo_remote_attr(*args, **kwargs):
242 242 return self._call(name, *args, **kwargs)
243 243
244 244 return repo_remote_attr
245 245
246 246 def _base_call(self, name, *args, **kwargs):
247 247 # TODO: oliver: This is currently necessary pre-call since the
248 248 # config object is being changed for hooking scenarios
249 249 wire = copy.deepcopy(self._wire)
250 250 wire["config"] = wire["config"].serialize()
251 251 wire["config"].append(('vcs', 'ssl_dir', self.cert_dir))
252 252
253 253 payload = {
254 254 'id': str(uuid.uuid4()),
255 255 'method': name,
256 256 "_repo_name": wire['_repo_name'],
257 257 'params': {'wire': wire, 'args': args, 'kwargs': kwargs}
258 258 }
259 259
260 260 context_uid = wire.get('context')
261 261 return context_uid, payload
262 262
263 263 def get_local_cache(self, name, args):
264 264 cache_on = False
265 265 cache_key = ''
266 266 local_cache_on = str2bool(rhodecode.CONFIG.get('vcs.methods.cache'))
267 267
268 268 cache_methods = [
269 269 'branches', 'tags', 'bookmarks',
270 270 'is_large_file', 'is_binary',
271 271 'fctx_size', 'stream:fctx_node_data', 'blob_raw_length',
272 272 'node_history',
273 273 'revision', 'tree_items',
274 274 'ctx_list', 'ctx_branch', 'ctx_description',
275 275 'bulk_request',
276 276 'assert_correct_path'
277 277 ]
278 278
279 279 if local_cache_on and name in cache_methods:
280 280 cache_on = True
281 281 repo_state_uid = self._wire['repo_state_uid']
282 282 call_args = [a for a in args]
283 283 cache_key = compute_key_from_params(repo_state_uid, name, *call_args)
284 284
285 285 return cache_on, cache_key
286 286
287 287 @exceptions.map_vcs_exceptions
288 288 def _call(self, name, *args, **kwargs):
289 289 context_uid, payload = self._base_call(name, *args, **kwargs)
290 290 url = self.url
291 291
292 292 start = time.time()
293 293 cache_on, cache_key = self.get_local_cache(name, args)
294 294
295 295 @self._cache_region.conditional_cache_on_arguments(
296 296 namespace=self._cache_namespace, condition=cache_on and cache_key)
297 297 def remote_call(_cache_key):
298 298 if self._call_with_logging:
299 299 log.debug('Calling %s@%s with args:%.10240r. wire_context: %s cache_on: %s',
300 300 url, name, args, context_uid, cache_on)
301 301 return _remote_call(url, payload, EXCEPTIONS_MAP, self._session)
302 302
303 303 result = remote_call(cache_key)
304 304 if self._call_with_logging:
305 305 log.debug('Call %s@%s took: %.4fs. wire_context: %s',
306 306 url, name, time.time()-start, context_uid)
307 307 return result
308 308
309 309 @exceptions.map_vcs_exceptions
310 310 def _call_stream(self, name, *args, **kwargs):
311 311 context_uid, payload = self._base_call(name, *args, **kwargs)
312 312 payload['chunk_size'] = self.CHUNK_SIZE
313 313 url = self.stream_url
314 314
315 315 start = time.time()
316 316 cache_on, cache_key = self.get_local_cache(name, args)
317 317
318 318 # Cache is a problem because this is a stream
319 319 def streaming_remote_call(_cache_key):
320 320 if self._call_with_logging:
321 321 log.debug('Calling %s@%s with args:%.10240r. wire_context: %s cache_on: %s',
322 322 url, name, args, context_uid, cache_on)
323 323 return _streaming_remote_call(url, payload, EXCEPTIONS_MAP, self._session, self.CHUNK_SIZE)
324 324
325 325 result = streaming_remote_call(cache_key)
326 326 if self._call_with_logging:
327 327 log.debug('Call %s@%s took: %.4fs. wire_context: %s',
328 328 url, name, time.time()-start, context_uid)
329 329 return result
330 330
331 331 def __getitem__(self, key):
332 332 return self.revision(key)
333 333
334 334 def _create_vcs_cache_context(self, *args):
335 335 """
336 336 Creates a unique string which is passed to the VCSServer on every
337 337 remote call. It is used as cache key in the VCSServer.
338 338 """
339 339 hash_key = '-'.join(map(str, args))
340 340 return str(uuid.uuid5(uuid.NAMESPACE_URL, hash_key))
341 341
342 342 def invalidate_vcs_cache(self):
343 343 """
344 344 This invalidates the context which is sent to the VCSServer on every
345 345 call to a remote method. It forces the VCSServer to create a fresh
346 346 repository instance on the next call to a remote method.
347 347 """
348 348 self._wire['context'] = str(uuid.uuid4())
349 349
350 350
351 351 class VcsHttpProxy(object):
352 352
353 353 CHUNK_SIZE = 16384
354 354
355 355 def __init__(self, server_and_port, backend_endpoint):
356 356 retries = Retry(total=5, connect=None, read=None, redirect=None)
357 357
358 358 adapter = requests.adapters.HTTPAdapter(max_retries=retries)
359 self.base_url = urllib.parse.urlparse.urljoin('http://%s' % server_and_port, backend_endpoint)
359 self.base_url = urllib.parse.urljoin('http://%s' % server_and_port, backend_endpoint)
360 360 self.session = requests.Session()
361 361 self.session.mount('http://', adapter)
362 362
363 363 def handle(self, environment, input_data, *args, **kwargs):
364 364 data = {
365 365 'environment': environment,
366 366 'input_data': input_data,
367 367 'args': args,
368 368 'kwargs': kwargs
369 369 }
370 370 result = self.session.post(
371 371 self.base_url, msgpack.packb(data), stream=True)
372 372 return self._get_result(result)
373 373
374 374 def _deserialize_and_raise(self, error):
375 375 exception = Exception(error['message'])
376 376 try:
377 377 exception._vcs_kind = error['_vcs_kind']
378 378 except KeyError:
379 379 pass
380 380 raise exception
381 381
382 382 def _iterate(self, result):
383 383 unpacker = msgpack.Unpacker()
384 384 for line in result.iter_content(chunk_size=self.CHUNK_SIZE):
385 385 unpacker.feed(line)
386 386 for chunk in unpacker:
387 387 yield chunk
388 388
389 389 def _get_result(self, result):
390 390 iterator = self._iterate(result)
391 391 error = next(iterator)
392 392 if error:
393 393 self._deserialize_and_raise(error)
394 394
395 395 status = next(iterator)
396 396 headers = next(iterator)
397 397
398 398 return iterator, status, headers
399 399
400 400
401 401 class ThreadlocalSessionFactory(object):
402 402 """
403 403 Creates one CurlSession per thread on demand.
404 404 """
405 405
406 406 def __init__(self):
407 407 self._thread_local = threading.local()
408 408
409 409 def __call__(self):
410 410 if not hasattr(self._thread_local, 'curl_session'):
411 411 self._thread_local.curl_session = CurlSession()
412 412 return self._thread_local.curl_session
@@ -1,140 +1,140 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import pytest
22 22 import urllib.parse
23 23 import mock
24 24 import simplejson as json
25 25
26 26 from rhodecode.lib.vcs.backends.base import Config
27 27 from rhodecode.tests.lib.middleware import mock_scm_app
28 28 import rhodecode.lib.middleware.simplegit as simplegit
29 29
30 30
31 31 def get_environ(url, request_method):
32 32 """Construct a minimum WSGI environ based on the URL."""
33 parsed_url = urllib.parse.urlparse.urlparse(url)
33 parsed_url = urllib.parse.urlparse(url)
34 34 environ = {
35 35 'PATH_INFO': parsed_url.path,
36 36 'QUERY_STRING': parsed_url.query,
37 37 'REQUEST_METHOD': request_method,
38 38 }
39 39
40 40 return environ
41 41
42 42
43 43 @pytest.mark.parametrize(
44 44 'url, expected_action, request_method',
45 45 [
46 46 ('/foo/bar/info/refs?service=git-upload-pack', 'pull', 'GET'),
47 47 ('/foo/bar/info/refs?service=git-receive-pack', 'push', 'GET'),
48 48 ('/foo/bar/git-upload-pack', 'pull', 'GET'),
49 49 ('/foo/bar/git-receive-pack', 'push', 'GET'),
50 50 # Edge case: missing data for info/refs
51 51 ('/foo/info/refs?service=', 'pull', 'GET'),
52 52 ('/foo/info/refs', 'pull', 'GET'),
53 53 # Edge case: git command comes with service argument
54 54 ('/foo/git-upload-pack?service=git-receive-pack', 'pull', 'GET'),
55 55 ('/foo/git-receive-pack?service=git-upload-pack', 'push', 'GET'),
56 56 # Edge case: repo name conflicts with git commands
57 57 ('/git-receive-pack/git-upload-pack', 'pull', 'GET'),
58 58 ('/git-receive-pack/git-receive-pack', 'push', 'GET'),
59 59 ('/git-upload-pack/git-upload-pack', 'pull', 'GET'),
60 60 ('/git-upload-pack/git-receive-pack', 'push', 'GET'),
61 61 ('/foo/git-receive-pack', 'push', 'GET'),
62 62 # Edge case: not a smart protocol url
63 63 ('/foo/bar', 'pull', 'GET'),
64 64 # GIT LFS cases, batch
65 65 ('/foo/bar/info/lfs/objects/batch', 'push', 'GET'),
66 66 ('/foo/bar/info/lfs/objects/batch', 'pull', 'POST'),
67 67 # GIT LFS oid, dl/upl
68 68 ('/foo/bar/info/lfs/abcdeabcde', 'pull', 'GET'),
69 69 ('/foo/bar/info/lfs/abcdeabcde', 'push', 'PUT'),
70 70 ('/foo/bar/info/lfs/abcdeabcde', 'push', 'POST'),
71 71 # Edge case: repo name conflicts with git commands
72 72 ('/info/lfs/info/lfs/objects/batch', 'push', 'GET'),
73 73 ('/info/lfs/info/lfs/objects/batch', 'pull', 'POST'),
74 74
75 75 ])
76 76 def test_get_action(url, expected_action, request_method, baseapp, request_stub):
77 77 app = simplegit.SimpleGit(config={'auth_ret_code': '', 'base_path': ''},
78 78 registry=request_stub.registry)
79 79 assert expected_action == app._get_action(get_environ(url, request_method))
80 80
81 81
82 82 @pytest.mark.parametrize(
83 83 'url, expected_repo_name, request_method',
84 84 [
85 85 ('/foo/info/refs?service=git-upload-pack', 'foo', 'GET'),
86 86 ('/foo/bar/info/refs?service=git-receive-pack', 'foo/bar', 'GET'),
87 87 ('/foo/git-upload-pack', 'foo', 'GET'),
88 88 ('/foo/git-receive-pack', 'foo', 'GET'),
89 89 ('/foo/bar/git-upload-pack', 'foo/bar', 'GET'),
90 90 ('/foo/bar/git-receive-pack', 'foo/bar', 'GET'),
91 91
92 92 # GIT LFS cases, batch
93 93 ('/foo/bar/info/lfs/objects/batch', 'foo/bar', 'GET'),
94 94 ('/example-git/info/lfs/objects/batch', 'example-git', 'POST'),
95 95 # GIT LFS oid, dl/upl
96 96 ('/foo/info/lfs/abcdeabcde', 'foo', 'GET'),
97 97 ('/foo/bar/info/lfs/abcdeabcde', 'foo/bar', 'PUT'),
98 98 ('/my-git-repo/info/lfs/abcdeabcde', 'my-git-repo', 'POST'),
99 99 # Edge case: repo name conflicts with git commands
100 100 ('/info/lfs/info/lfs/objects/batch', 'info/lfs', 'GET'),
101 101 ('/info/lfs/info/lfs/objects/batch', 'info/lfs', 'POST'),
102 102
103 103 ])
104 104 def test_get_repository_name(url, expected_repo_name, request_method, baseapp, request_stub):
105 105 app = simplegit.SimpleGit(config={'auth_ret_code': '', 'base_path': ''},
106 106 registry=request_stub.registry)
107 107 assert expected_repo_name == app._get_repository_name(
108 108 get_environ(url, request_method))
109 109
110 110
111 111 def test_get_config(user_util, baseapp, request_stub):
112 112 repo = user_util.create_repo(repo_type='git')
113 113 app = simplegit.SimpleGit(config={'auth_ret_code': '', 'base_path': ''},
114 114 registry=request_stub.registry)
115 115 extras = {'foo': 'FOO', 'bar': 'BAR'}
116 116
117 117 # We copy the extras as the method below will change the contents.
118 118 git_config = app._create_config(dict(extras), repo_name=repo.repo_name)
119 119
120 120 expected_config = dict(extras)
121 121 expected_config.update({
122 122 'git_update_server_info': False,
123 123 'git_lfs_enabled': False,
124 124 'git_lfs_store_path': git_config['git_lfs_store_path'],
125 125 'git_lfs_http_scheme': 'http'
126 126 })
127 127
128 128 assert git_config == expected_config
129 129
130 130
131 131 def test_create_wsgi_app_uses_scm_app_from_simplevcs(baseapp, request_stub):
132 132 config = {
133 133 'auth_ret_code': '',
134 134 'base_path': '',
135 135 'vcs.scm_app_implementation':
136 136 'rhodecode.tests.lib.middleware.mock_scm_app',
137 137 }
138 138 app = simplegit.SimpleGit(config=config, registry=request_stub.registry)
139 139 wsgi_app = app._create_wsgi_app('/tmp/test', 'test_repo', {})
140 140 assert wsgi_app is mock_scm_app.mock_git_wsgi
@@ -1,156 +1,156 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import urllib.parse
22 22
23 23 import mock
24 24 import pytest
25 25 import simplejson as json
26 26
27 27 from rhodecode.lib.vcs.backends.base import Config
28 28 from rhodecode.tests.lib.middleware import mock_scm_app
29 29 import rhodecode.lib.middleware.simplehg as simplehg
30 30
31 31
32 32 def get_environ(url):
33 33 """Construct a minimum WSGI environ based on the URL."""
34 parsed_url = urllib.parse.urlparse.urlparse(url)
34 parsed_url = urllib.parse.urlparse(url)
35 35 environ = {
36 36 'PATH_INFO': parsed_url.path,
37 37 'QUERY_STRING': parsed_url.query,
38 38 }
39 39
40 40 return environ
41 41
42 42
43 43 @pytest.mark.parametrize(
44 44 'url, expected_action',
45 45 [
46 46 ('/foo/bar?cmd=unbundle&key=tip', 'push'),
47 47 ('/foo/bar?cmd=pushkey&key=tip', 'push'),
48 48 ('/foo/bar?cmd=listkeys&key=tip', 'pull'),
49 49 ('/foo/bar?cmd=changegroup&key=tip', 'pull'),
50 50 ('/foo/bar?cmd=hello', 'pull'),
51 51 ('/foo/bar?cmd=batch', 'push'),
52 52 ('/foo/bar?cmd=putlfile', 'push'),
53 53 # Edge case: unknown argument: assume push
54 54 ('/foo/bar?cmd=unknown&key=tip', 'push'),
55 55 ('/foo/bar?cmd=&key=tip', 'push'),
56 56 # Edge case: not cmd argument
57 57 ('/foo/bar?key=tip', 'push'),
58 58 ])
59 59 def test_get_action(url, expected_action, request_stub):
60 60 app = simplehg.SimpleHg(config={'auth_ret_code': '', 'base_path': ''},
61 61 registry=request_stub.registry)
62 62 assert expected_action == app._get_action(get_environ(url))
63 63
64 64
65 65 @pytest.mark.parametrize(
66 66 'environ, expected_xargs, expected_batch',
67 67 [
68 68 ({},
69 69 [''], ['push']),
70 70
71 71 ({'HTTP_X_HGARG_1': ''},
72 72 [''], ['push']),
73 73
74 74 ({'HTTP_X_HGARG_1': 'cmds=listkeys+namespace%3Dphases'},
75 75 ['listkeys namespace=phases'], ['pull']),
76 76
77 77 ({'HTTP_X_HGARG_1': 'cmds=pushkey+namespace%3Dbookmarks%2Ckey%3Dbm%2Cold%3D%2Cnew%3Dcb9a9f314b8b07ba71012fcdbc544b5a4d82ff5b'},
78 78 ['pushkey namespace=bookmarks,key=bm,old=,new=cb9a9f314b8b07ba71012fcdbc544b5a4d82ff5b'], ['push']),
79 79
80 80 ({'HTTP_X_HGARG_1': 'namespace=phases'},
81 81 ['namespace=phases'], ['push']),
82 82
83 83 ])
84 84 def test_xarg_and_batch_commands(environ, expected_xargs, expected_batch):
85 85 app = simplehg.SimpleHg
86 86
87 87 result = app._get_xarg_headers(environ)
88 88 result_batch = app._get_batch_cmd(environ)
89 89 assert expected_xargs == result
90 90 assert expected_batch == result_batch
91 91
92 92
93 93 @pytest.mark.parametrize(
94 94 'url, expected_repo_name',
95 95 [
96 96 ('/foo?cmd=unbundle&key=tip', 'foo'),
97 97 ('/foo/bar?cmd=pushkey&key=tip', 'foo/bar'),
98 98 ('/foo/bar/baz?cmd=listkeys&key=tip', 'foo/bar/baz'),
99 99 # Repos with trailing slashes.
100 100 ('/foo/?cmd=unbundle&key=tip', 'foo'),
101 101 ('/foo/bar/?cmd=pushkey&key=tip', 'foo/bar'),
102 102 ('/foo/bar/baz/?cmd=listkeys&key=tip', 'foo/bar/baz'),
103 103 ])
104 104 def test_get_repository_name(url, expected_repo_name, request_stub):
105 105 app = simplehg.SimpleHg(config={'auth_ret_code': '', 'base_path': ''},
106 106 registry=request_stub.registry)
107 107 assert expected_repo_name == app._get_repository_name(get_environ(url))
108 108
109 109
110 110 def test_get_config(user_util, baseapp, request_stub):
111 111 repo = user_util.create_repo(repo_type='git')
112 112 app = simplehg.SimpleHg(config={'auth_ret_code': '', 'base_path': ''},
113 113 registry=request_stub.registry)
114 114 extras = [('foo', 'FOO', 'bar', 'BAR')]
115 115
116 116 hg_config = app._create_config(extras, repo_name=repo.repo_name)
117 117
118 118 config = simplehg.utils.make_db_config(repo=repo.repo_name)
119 119 config.set('rhodecode', 'RC_SCM_DATA', json.dumps(extras))
120 120 hg_config_org = config
121 121
122 122 expected_config = [
123 123 ('vcs_svn_tag', 'ff89f8c714d135d865f44b90e5413b88de19a55f', '/tags/*'),
124 124 ('web', 'push_ssl', 'False'),
125 125 ('web', 'allow_push', '*'),
126 126 ('web', 'allow_archive', 'gz zip bz2'),
127 127 ('web', 'baseurl', '/'),
128 128 ('vcs_git_lfs', 'store_location', hg_config_org.get('vcs_git_lfs', 'store_location')),
129 129 ('vcs_svn_branch', '9aac1a38c3b8a0cdc4ae0f960a5f83332bc4fa5e', '/branches/*'),
130 130 ('vcs_svn_branch', 'c7e6a611c87da06529fd0dd733308481d67c71a8', '/trunk'),
131 131 ('largefiles', 'usercache', hg_config_org.get('largefiles', 'usercache')),
132 132 ('hooks', 'preoutgoing.pre_pull', 'python:vcsserver.hooks.pre_pull'),
133 133 ('hooks', 'prechangegroup.pre_push', 'python:vcsserver.hooks.pre_push'),
134 134 ('hooks', 'outgoing.pull_logger', 'python:vcsserver.hooks.log_pull_action'),
135 135 ('hooks', 'pretxnchangegroup.pre_push', 'python:vcsserver.hooks.pre_push'),
136 136 ('hooks', 'changegroup.push_logger', 'python:vcsserver.hooks.log_push_action'),
137 137 ('hooks', 'changegroup.repo_size', 'python:vcsserver.hooks.repo_size'),
138 138 ('phases', 'publish', 'True'),
139 139 ('extensions', 'largefiles', ''),
140 140 ('paths', '/', hg_config_org.get('paths', '/')),
141 141 ('rhodecode', 'RC_SCM_DATA', '[["foo", "FOO", "bar", "BAR"]]')
142 142 ]
143 143 for entry in expected_config:
144 144 assert entry in hg_config
145 145
146 146
147 147 def test_create_wsgi_app_uses_scm_app_from_simplevcs(request_stub):
148 148 config = {
149 149 'auth_ret_code': '',
150 150 'base_path': '',
151 151 'vcs.scm_app_implementation':
152 152 'rhodecode.tests.lib.middleware.mock_scm_app',
153 153 }
154 154 app = simplehg.SimpleHg(config=config, registry=request_stub.registry)
155 155 wsgi_app = app._create_wsgi_app('/tmp/test', 'test_repo', {})
156 156 assert wsgi_app is mock_scm_app.mock_hg_wsgi
@@ -1,468 +1,467 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2020 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import threading
22 22 import time
23 23 import logging
24 24 import os.path
25 25 import subprocess
26 26 import tempfile
27 27 import urllib.request, urllib.error, urllib.parse
28 28 from lxml.html import fromstring, tostring
29 29 from lxml.cssselect import CSSSelector
30 import urllib.parse.urlparse
31 30 from urllib.parse import unquote_plus
32 31 import webob
33 32
34 33 from webtest.app import TestResponse, TestApp
35 34 from webtest.compat import print_stderr
36 35
37 36 import pytest
38 37 import rc_testdata
39 38
40 39 from rhodecode.model.db import User, Repository
41 40 from rhodecode.model.meta import Session
42 41 from rhodecode.model.scm import ScmModel
43 42 from rhodecode.lib.vcs.backends.svn.repository import SubversionRepository
44 43 from rhodecode.lib.vcs.backends.base import EmptyCommit
45 44 from rhodecode.tests import login_user_session
46 45
47 46 log = logging.getLogger(__name__)
48 47
49 48
50 49 class CustomTestResponse(TestResponse):
51 50
52 51 def _save_output(self, out):
53 52 f = tempfile.NamedTemporaryFile(delete=False, prefix='rc-test-', suffix='.html')
54 53 f.write(out)
55 54 return f.name
56 55
57 56 def mustcontain(self, *strings, **kw):
58 57 """
59 58 Assert that the response contains all of the strings passed
60 59 in as arguments.
61 60
62 61 Equivalent to::
63 62
64 63 assert string in res
65 64 """
66 65 print_body = kw.pop('print_body', False)
67 66 if 'no' in kw:
68 67 no = kw['no']
69 68 del kw['no']
70 69 if isinstance(no, str):
71 70 no = [no]
72 71 else:
73 72 no = []
74 73 if kw:
75 74 raise TypeError(
76 75 "The only keyword argument allowed is 'no' got %s" % kw)
77 76
78 77 f = self._save_output(str(self))
79 78
80 79 for s in strings:
81 80 if not s in self:
82 81 print_stderr("Actual response (no %r):" % s)
83 82 print_stderr("body output saved as `%s`" % f)
84 83 if print_body:
85 84 print_stderr(str(self))
86 85 raise IndexError(
87 86 "Body does not contain string %r, body output saved as %s" % (s, f))
88 87
89 88 for no_s in no:
90 89 if no_s in self:
91 90 print_stderr("Actual response (has %r)" % no_s)
92 91 print_stderr("body output saved as `%s`" % f)
93 92 if print_body:
94 93 print_stderr(str(self))
95 94 raise IndexError(
96 95 "Body contains bad string %r, body output saved as %s" % (no_s, f))
97 96
98 97 def assert_response(self):
99 98 return AssertResponse(self)
100 99
101 100 def get_session_from_response(self):
102 101 """
103 102 This returns the session from a response object.
104 103 """
105 104 from rhodecode.lib.rc_beaker import session_factory_from_settings
106 105 session = session_factory_from_settings(self.test_app._pyramid_settings)
107 106 return session(self.request)
108 107
109 108
110 109 class TestRequest(webob.BaseRequest):
111 110
112 111 # for py.test
113 112 disabled = True
114 113 ResponseClass = CustomTestResponse
115 114
116 115 def add_response_callback(self, callback):
117 116 pass
118 117
119 118
120 119 class CustomTestApp(TestApp):
121 120 """
122 121 Custom app to make mustcontain more Useful, and extract special methods
123 122 """
124 123 RequestClass = TestRequest
125 124 rc_login_data = {}
126 125 rc_current_session = None
127 126
128 127 def login(self, username=None, password=None):
129 128 from rhodecode.lib import auth
130 129
131 130 if username and password:
132 131 session = login_user_session(self, username, password)
133 132 else:
134 133 session = login_user_session(self)
135 134
136 135 self.rc_login_data['csrf_token'] = auth.get_csrf_token(session)
137 136 self.rc_current_session = session
138 137 return session['rhodecode_user']
139 138
140 139 @property
141 140 def csrf_token(self):
142 141 return self.rc_login_data['csrf_token']
143 142
144 143 @property
145 144 def _pyramid_registry(self):
146 145 return self.app.config.registry
147 146
148 147 @property
149 148 def _pyramid_settings(self):
150 149 return self._pyramid_registry.settings
151 150
152 151
153 152 def set_anonymous_access(enabled):
154 153 """(Dis)allows anonymous access depending on parameter `enabled`"""
155 154 user = User.get_default_user()
156 155 user.active = enabled
157 156 Session().add(user)
158 157 Session().commit()
159 158 time.sleep(1.5) # must sleep for cache (1s to expire)
160 159 log.info('anonymous access is now: %s', enabled)
161 160 assert enabled == User.get_default_user().active, (
162 161 'Cannot set anonymous access')
163 162
164 163
165 164 def check_xfail_backends(node, backend_alias):
166 165 # Using "xfail_backends" here intentionally, since this marks work
167 166 # which is "to be done" soon.
168 167 skip_marker = node.get_closest_marker('xfail_backends')
169 168 if skip_marker and backend_alias in skip_marker.args:
170 169 msg = "Support for backend %s to be developed." % (backend_alias, )
171 170 msg = skip_marker.kwargs.get('reason', msg)
172 171 pytest.xfail(msg)
173 172
174 173
175 174 def check_skip_backends(node, backend_alias):
176 175 # Using "skip_backends" here intentionally, since this marks work which is
177 176 # not supported.
178 177 skip_marker = node.get_closest_marker('skip_backends')
179 178 if skip_marker and backend_alias in skip_marker.args:
180 179 msg = "Feature not supported for backend %s." % (backend_alias, )
181 180 msg = skip_marker.kwargs.get('reason', msg)
182 181 pytest.skip(msg)
183 182
184 183
185 184 def extract_git_repo_from_dump(dump_name, repo_name):
186 185 """Create git repo `repo_name` from dump `dump_name`."""
187 186 repos_path = ScmModel().repos_path
188 187 target_path = os.path.join(repos_path, repo_name)
189 188 rc_testdata.extract_git_dump(dump_name, target_path)
190 189 return target_path
191 190
192 191
193 192 def extract_hg_repo_from_dump(dump_name, repo_name):
194 193 """Create hg repo `repo_name` from dump `dump_name`."""
195 194 repos_path = ScmModel().repos_path
196 195 target_path = os.path.join(repos_path, repo_name)
197 196 rc_testdata.extract_hg_dump(dump_name, target_path)
198 197 return target_path
199 198
200 199
201 200 def extract_svn_repo_from_dump(dump_name, repo_name):
202 201 """Create a svn repo `repo_name` from dump `dump_name`."""
203 202 repos_path = ScmModel().repos_path
204 203 target_path = os.path.join(repos_path, repo_name)
205 204 SubversionRepository(target_path, create=True)
206 205 _load_svn_dump_into_repo(dump_name, target_path)
207 206 return target_path
208 207
209 208
210 209 def assert_message_in_log(log_records, message, levelno, module):
211 210 messages = [
212 211 r.message for r in log_records
213 212 if r.module == module and r.levelno == levelno
214 213 ]
215 214 assert message in messages
216 215
217 216
218 217 def _load_svn_dump_into_repo(dump_name, repo_path):
219 218 """
220 219 Utility to populate a svn repository with a named dump
221 220
222 221 Currently the dumps are in rc_testdata. They might later on be
223 222 integrated with the main repository once they stabilize more.
224 223 """
225 224 dump = rc_testdata.load_svn_dump(dump_name)
226 225 load_dump = subprocess.Popen(
227 226 ['svnadmin', 'load', repo_path],
228 227 stdin=subprocess.PIPE, stdout=subprocess.PIPE,
229 228 stderr=subprocess.PIPE)
230 229 out, err = load_dump.communicate(dump)
231 230 if load_dump.returncode != 0:
232 231 log.error("Output of load_dump command: %s", out)
233 232 log.error("Error output of load_dump command: %s", err)
234 233 raise Exception(
235 234 'Failed to load dump "%s" into repository at path "%s".'
236 235 % (dump_name, repo_path))
237 236
238 237
239 238 class AssertResponse(object):
240 239 """
241 240 Utility that helps to assert things about a given HTML response.
242 241 """
243 242
244 243 def __init__(self, response):
245 244 self.response = response
246 245
247 246 def get_imports(self):
248 247 return fromstring, tostring, CSSSelector
249 248
250 249 def one_element_exists(self, css_selector):
251 250 self.get_element(css_selector)
252 251
253 252 def no_element_exists(self, css_selector):
254 253 assert not self._get_elements(css_selector)
255 254
256 255 def element_equals_to(self, css_selector, expected_content):
257 256 element = self.get_element(css_selector)
258 257 element_text = self._element_to_string(element)
259 258 assert expected_content in element_text
260 259
261 260 def element_contains(self, css_selector, expected_content):
262 261 element = self.get_element(css_selector)
263 262 assert expected_content in element.text_content()
264 263
265 264 def element_value_contains(self, css_selector, expected_content):
266 265 element = self.get_element(css_selector)
267 266 assert expected_content in element.value
268 267
269 268 def contains_one_link(self, link_text, href):
270 269 fromstring, tostring, CSSSelector = self.get_imports()
271 270 doc = fromstring(self.response.body)
272 271 sel = CSSSelector('a[href]')
273 272 elements = [
274 273 e for e in sel(doc) if e.text_content().strip() == link_text]
275 274 assert len(elements) == 1, "Did not find link or found multiple links"
276 275 self._ensure_url_equal(elements[0].attrib.get('href'), href)
277 276
278 277 def contains_one_anchor(self, anchor_id):
279 278 fromstring, tostring, CSSSelector = self.get_imports()
280 279 doc = fromstring(self.response.body)
281 280 sel = CSSSelector('#' + anchor_id)
282 281 elements = sel(doc)
283 282 assert len(elements) == 1, 'cannot find 1 element {}'.format(anchor_id)
284 283
285 284 def _ensure_url_equal(self, found, expected):
286 285 assert _Url(found) == _Url(expected)
287 286
288 287 def get_element(self, css_selector):
289 288 elements = self._get_elements(css_selector)
290 289 assert len(elements) == 1, 'cannot find 1 element {}'.format(css_selector)
291 290 return elements[0]
292 291
293 292 def get_elements(self, css_selector):
294 293 return self._get_elements(css_selector)
295 294
296 295 def _get_elements(self, css_selector):
297 296 fromstring, tostring, CSSSelector = self.get_imports()
298 297 doc = fromstring(self.response.body)
299 298 sel = CSSSelector(css_selector)
300 299 elements = sel(doc)
301 300 return elements
302 301
303 302 def _element_to_string(self, element):
304 303 fromstring, tostring, CSSSelector = self.get_imports()
305 304 return tostring(element)
306 305
307 306
308 307 class _Url(object):
309 308 """
310 309 A url object that can be compared with other url orbjects
311 310 without regard to the vagaries of encoding, escaping, and ordering
312 311 of parameters in query strings.
313 312
314 313 Inspired by
315 314 http://stackoverflow.com/questions/5371992/comparing-two-urls-in-python
316 315 """
317 316
318 317 def __init__(self, url):
319 318 parts = urllib.parse.urlparse(url)
320 319 _query = frozenset(urllib.parse.parse_qsl(parts.query))
321 320 _path = unquote_plus(parts.path)
322 321 parts = parts._replace(query=_query, path=_path)
323 322 self.parts = parts
324 323
325 324 def __eq__(self, other):
326 325 return self.parts == other.parts
327 326
328 327 def __hash__(self):
329 328 return hash(self.parts)
330 329
331 330
332 331 def run_test_concurrently(times, raise_catched_exc=True):
333 332 """
334 333 Add this decorator to small pieces of code that you want to test
335 334 concurrently
336 335
337 336 ex:
338 337
339 338 @test_concurrently(25)
340 339 def my_test_function():
341 340 ...
342 341 """
343 342 def test_concurrently_decorator(test_func):
344 343 def wrapper(*args, **kwargs):
345 344 exceptions = []
346 345
347 346 def call_test_func():
348 347 try:
349 348 test_func(*args, **kwargs)
350 349 except Exception as e:
351 350 exceptions.append(e)
352 351 if raise_catched_exc:
353 352 raise
354 353 threads = []
355 354 for i in range(times):
356 355 threads.append(threading.Thread(target=call_test_func))
357 356 for t in threads:
358 357 t.start()
359 358 for t in threads:
360 359 t.join()
361 360 if exceptions:
362 361 raise Exception(
363 362 'test_concurrently intercepted %s exceptions: %s' % (
364 363 len(exceptions), exceptions))
365 364 return wrapper
366 365 return test_concurrently_decorator
367 366
368 367
369 368 def wait_for_url(url, timeout=10):
370 369 """
371 370 Wait until URL becomes reachable.
372 371
373 372 It polls the URL until the timeout is reached or it became reachable.
374 373 If will call to `py.test.fail` in case the URL is not reachable.
375 374 """
376 375 timeout = time.time() + timeout
377 376 last = 0
378 377 wait = 0.1
379 378
380 379 while timeout > last:
381 380 last = time.time()
382 381 if is_url_reachable(url):
383 382 break
384 383 elif (last + wait) > time.time():
385 384 # Go to sleep because not enough time has passed since last check.
386 385 time.sleep(wait)
387 386 else:
388 387 pytest.fail("Timeout while waiting for URL {}".format(url))
389 388
390 389
391 390 def is_url_reachable(url):
392 391 try:
393 392 urllib.request.urlopen(url)
394 393 except urllib.error.URLError:
395 394 log.exception('URL `{}` reach error'.format(url))
396 395 return False
397 396 return True
398 397
399 398
400 399 def repo_on_filesystem(repo_name):
401 400 from rhodecode.lib import vcs
402 401 from rhodecode.tests import TESTS_TMP_PATH
403 402 repo = vcs.get_vcs_instance(
404 403 os.path.join(TESTS_TMP_PATH, repo_name), create=False)
405 404 return repo is not None
406 405
407 406
408 407 def commit_change(
409 408 repo, filename, content, message, vcs_type, parent=None, newfile=False):
410 409 from rhodecode.tests import TEST_USER_ADMIN_LOGIN
411 410
412 411 repo = Repository.get_by_repo_name(repo)
413 412 _commit = parent
414 413 if not parent:
415 414 _commit = EmptyCommit(alias=vcs_type)
416 415
417 416 if newfile:
418 417 nodes = {
419 418 filename: {
420 419 'content': content
421 420 }
422 421 }
423 422 commit = ScmModel().create_nodes(
424 423 user=TEST_USER_ADMIN_LOGIN, repo=repo,
425 424 message=message,
426 425 nodes=nodes,
427 426 parent_commit=_commit,
428 427 author='{} <admin@rhodecode.com>'.format(TEST_USER_ADMIN_LOGIN),
429 428 )
430 429 else:
431 430 commit = ScmModel().commit_change(
432 431 repo=repo.scm_instance(), repo_name=repo.repo_name,
433 432 commit=parent, user=TEST_USER_ADMIN_LOGIN,
434 433 author='{} <admin@rhodecode.com>'.format(TEST_USER_ADMIN_LOGIN),
435 434 message=message,
436 435 content=content,
437 436 f_path=filename
438 437 )
439 438 return commit
440 439
441 440
442 441 def permission_update_data_generator(csrf_token, default=None, grant=None, revoke=None):
443 442 if not default:
444 443 raise ValueError('Permission for default user must be given')
445 444 form_data = [(
446 445 'csrf_token', csrf_token
447 446 )]
448 447 # add default
449 448 form_data.extend([
450 449 ('u_perm_1', default)
451 450 ])
452 451
453 452 if grant:
454 453 for cnt, (obj_id, perm, obj_name, obj_type) in enumerate(grant, 1):
455 454 form_data.extend([
456 455 ('perm_new_member_perm_new{}'.format(cnt), perm),
457 456 ('perm_new_member_id_new{}'.format(cnt), obj_id),
458 457 ('perm_new_member_name_new{}'.format(cnt), obj_name),
459 458 ('perm_new_member_type_new{}'.format(cnt), obj_type),
460 459
461 460 ])
462 461 if revoke:
463 462 for obj_id, obj_type in revoke:
464 463 form_data.extend([
465 464 ('perm_del_member_id_{}'.format(obj_id), obj_id),
466 465 ('perm_del_member_type_{}'.format(obj_id), obj_type),
467 466 ])
468 467 return form_data
General Comments 0
You need to be logged in to leave comments. Login now