##// END OF EJS Templates
vcs: Add flag to indicate if repository is a shadow repository.
Martin Bornhold -
r899:3c9ebe4f default
parent child Browse files
Show More
@@ -1,590 +1,592 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2016 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 The base Controller API
23 23 Provides the BaseController class for subclassing. And usage in different
24 24 controllers
25 25 """
26 26
27 27 import logging
28 28 import socket
29 29
30 30 import ipaddress
31 31 import pyramid.threadlocal
32 32
33 33 from paste.auth.basic import AuthBasicAuthenticator
34 34 from paste.httpexceptions import HTTPUnauthorized, HTTPForbidden, get_exception
35 35 from paste.httpheaders import WWW_AUTHENTICATE, AUTHORIZATION
36 36 from pylons import config, tmpl_context as c, request, session, url
37 37 from pylons.controllers import WSGIController
38 38 from pylons.controllers.util import redirect
39 39 from pylons.i18n import translation
40 40 # marcink: don't remove this import
41 41 from pylons.templating import render_mako as render # noqa
42 42 from pylons.i18n.translation import _
43 43 from webob.exc import HTTPFound
44 44
45 45
46 46 import rhodecode
47 47 from rhodecode.authentication.base import VCS_TYPE
48 48 from rhodecode.lib import auth, utils2
49 49 from rhodecode.lib import helpers as h
50 50 from rhodecode.lib.auth import AuthUser, CookieStoreWrapper
51 51 from rhodecode.lib.exceptions import UserCreationError
52 52 from rhodecode.lib.utils import (
53 53 get_repo_slug, set_rhodecode_config, password_changed,
54 54 get_enabled_hook_classes)
55 55 from rhodecode.lib.utils2 import (
56 56 str2bool, safe_unicode, AttributeDict, safe_int, md5, aslist)
57 57 from rhodecode.lib.vcs.exceptions import RepositoryRequirementError
58 58 from rhodecode.model import meta
59 59 from rhodecode.model.db import Repository, User
60 60 from rhodecode.model.notification import NotificationModel
61 61 from rhodecode.model.scm import ScmModel
62 62 from rhodecode.model.settings import VcsSettingsModel, SettingsModel
63 63
64 64
65 65 log = logging.getLogger(__name__)
66 66
67 67
68 68 def _filter_proxy(ip):
69 69 """
70 70 Passed in IP addresses in HEADERS can be in a special format of multiple
71 71 ips. Those comma separated IPs are passed from various proxies in the
72 72 chain of request processing. The left-most being the original client.
73 73 We only care about the first IP which came from the org. client.
74 74
75 75 :param ip: ip string from headers
76 76 """
77 77 if ',' in ip:
78 78 _ips = ip.split(',')
79 79 _first_ip = _ips[0].strip()
80 80 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
81 81 return _first_ip
82 82 return ip
83 83
84 84
85 85 def _filter_port(ip):
86 86 """
87 87 Removes a port from ip, there are 4 main cases to handle here.
88 88 - ipv4 eg. 127.0.0.1
89 89 - ipv6 eg. ::1
90 90 - ipv4+port eg. 127.0.0.1:8080
91 91 - ipv6+port eg. [::1]:8080
92 92
93 93 :param ip:
94 94 """
95 95 def is_ipv6(ip_addr):
96 96 if hasattr(socket, 'inet_pton'):
97 97 try:
98 98 socket.inet_pton(socket.AF_INET6, ip_addr)
99 99 except socket.error:
100 100 return False
101 101 else:
102 102 # fallback to ipaddress
103 103 try:
104 104 ipaddress.IPv6Address(ip_addr)
105 105 except Exception:
106 106 return False
107 107 return True
108 108
109 109 if ':' not in ip: # must be ipv4 pure ip
110 110 return ip
111 111
112 112 if '[' in ip and ']' in ip: # ipv6 with port
113 113 return ip.split(']')[0][1:].lower()
114 114
115 115 # must be ipv6 or ipv4 with port
116 116 if is_ipv6(ip):
117 117 return ip
118 118 else:
119 119 ip, _port = ip.split(':')[:2] # means ipv4+port
120 120 return ip
121 121
122 122
123 123 def get_ip_addr(environ):
124 124 proxy_key = 'HTTP_X_REAL_IP'
125 125 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
126 126 def_key = 'REMOTE_ADDR'
127 127 _filters = lambda x: _filter_port(_filter_proxy(x))
128 128
129 129 ip = environ.get(proxy_key)
130 130 if ip:
131 131 return _filters(ip)
132 132
133 133 ip = environ.get(proxy_key2)
134 134 if ip:
135 135 return _filters(ip)
136 136
137 137 ip = environ.get(def_key, '0.0.0.0')
138 138 return _filters(ip)
139 139
140 140
141 141 def get_server_ip_addr(environ, log_errors=True):
142 142 hostname = environ.get('SERVER_NAME')
143 143 try:
144 144 return socket.gethostbyname(hostname)
145 145 except Exception as e:
146 146 if log_errors:
147 147 # in some cases this lookup is not possible, and we don't want to
148 148 # make it an exception in logs
149 149 log.exception('Could not retrieve server ip address: %s', e)
150 150 return hostname
151 151
152 152
153 153 def get_server_port(environ):
154 154 return environ.get('SERVER_PORT')
155 155
156 156
157 157 def get_access_path(environ):
158 158 path = environ.get('PATH_INFO')
159 159 org_req = environ.get('pylons.original_request')
160 160 if org_req:
161 161 path = org_req.environ.get('PATH_INFO')
162 162 return path
163 163
164 164
165 165 def vcs_operation_context(
166 environ, repo_name, username, action, scm, check_locking=True):
166 environ, repo_name, username, action, scm, check_locking=True,
167 is_shadow_repo=False):
167 168 """
168 169 Generate the context for a vcs operation, e.g. push or pull.
169 170
170 171 This context is passed over the layers so that hooks triggered by the
171 172 vcs operation know details like the user, the user's IP address etc.
172 173
173 174 :param check_locking: Allows to switch of the computation of the locking
174 175 data. This serves mainly the need of the simplevcs middleware to be
175 176 able to disable this for certain operations.
176 177
177 178 """
178 179 # Tri-state value: False: unlock, None: nothing, True: lock
179 180 make_lock = None
180 181 locked_by = [None, None, None]
181 182 is_anonymous = username == User.DEFAULT_USER
182 183 if not is_anonymous and check_locking:
183 184 log.debug('Checking locking on repository "%s"', repo_name)
184 185 user = User.get_by_username(username)
185 186 repo = Repository.get_by_repo_name(repo_name)
186 187 make_lock, __, locked_by = repo.get_locking_state(
187 188 action, user.user_id)
188 189
189 190 settings_model = VcsSettingsModel(repo=repo_name)
190 191 ui_settings = settings_model.get_ui_settings()
191 192
192 193 extras = {
193 194 'ip': get_ip_addr(environ),
194 195 'username': username,
195 196 'action': action,
196 197 'repository': repo_name,
197 198 'scm': scm,
198 199 'config': rhodecode.CONFIG['__file__'],
199 200 'make_lock': make_lock,
200 201 'locked_by': locked_by,
201 202 'server_url': utils2.get_server_url(environ),
202 203 'hooks': get_enabled_hook_classes(ui_settings),
204 'is_shadow_repo': is_shadow_repo,
203 205 }
204 206 return extras
205 207
206 208
207 209 class BasicAuth(AuthBasicAuthenticator):
208 210
209 211 def __init__(self, realm, authfunc, registry, auth_http_code=None,
210 212 initial_call_detection=False):
211 213 self.realm = realm
212 214 self.initial_call = initial_call_detection
213 215 self.authfunc = authfunc
214 216 self.registry = registry
215 217 self._rc_auth_http_code = auth_http_code
216 218
217 219 def _get_response_from_code(self, http_code):
218 220 try:
219 221 return get_exception(safe_int(http_code))
220 222 except Exception:
221 223 log.exception('Failed to fetch response for code %s' % http_code)
222 224 return HTTPForbidden
223 225
224 226 def build_authentication(self):
225 227 head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
226 228 if self._rc_auth_http_code and not self.initial_call:
227 229 # return alternative HTTP code if alternative http return code
228 230 # is specified in RhodeCode config, but ONLY if it's not the
229 231 # FIRST call
230 232 custom_response_klass = self._get_response_from_code(
231 233 self._rc_auth_http_code)
232 234 return custom_response_klass(headers=head)
233 235 return HTTPUnauthorized(headers=head)
234 236
235 237 def authenticate(self, environ):
236 238 authorization = AUTHORIZATION(environ)
237 239 if not authorization:
238 240 return self.build_authentication()
239 241 (authmeth, auth) = authorization.split(' ', 1)
240 242 if 'basic' != authmeth.lower():
241 243 return self.build_authentication()
242 244 auth = auth.strip().decode('base64')
243 245 _parts = auth.split(':', 1)
244 246 if len(_parts) == 2:
245 247 username, password = _parts
246 248 if self.authfunc(
247 249 username, password, environ, VCS_TYPE,
248 250 registry=self.registry):
249 251 return username
250 252 if username and password:
251 253 # we mark that we actually executed authentication once, at
252 254 # that point we can use the alternative auth code
253 255 self.initial_call = False
254 256
255 257 return self.build_authentication()
256 258
257 259 __call__ = authenticate
258 260
259 261
260 262 def attach_context_attributes(context, request):
261 263 """
262 264 Attach variables into template context called `c`, please note that
263 265 request could be pylons or pyramid request in here.
264 266 """
265 267 rc_config = SettingsModel().get_all_settings(cache=True)
266 268
267 269 context.rhodecode_version = rhodecode.__version__
268 270 context.rhodecode_edition = config.get('rhodecode.edition')
269 271 # unique secret + version does not leak the version but keep consistency
270 272 context.rhodecode_version_hash = md5(
271 273 config.get('beaker.session.secret', '') +
272 274 rhodecode.__version__)[:8]
273 275
274 276 # Default language set for the incoming request
275 277 context.language = translation.get_lang()[0]
276 278
277 279 # Visual options
278 280 context.visual = AttributeDict({})
279 281
280 282 # DB stored Visual Items
281 283 context.visual.show_public_icon = str2bool(
282 284 rc_config.get('rhodecode_show_public_icon'))
283 285 context.visual.show_private_icon = str2bool(
284 286 rc_config.get('rhodecode_show_private_icon'))
285 287 context.visual.stylify_metatags = str2bool(
286 288 rc_config.get('rhodecode_stylify_metatags'))
287 289 context.visual.dashboard_items = safe_int(
288 290 rc_config.get('rhodecode_dashboard_items', 100))
289 291 context.visual.admin_grid_items = safe_int(
290 292 rc_config.get('rhodecode_admin_grid_items', 100))
291 293 context.visual.repository_fields = str2bool(
292 294 rc_config.get('rhodecode_repository_fields'))
293 295 context.visual.show_version = str2bool(
294 296 rc_config.get('rhodecode_show_version'))
295 297 context.visual.use_gravatar = str2bool(
296 298 rc_config.get('rhodecode_use_gravatar'))
297 299 context.visual.gravatar_url = rc_config.get('rhodecode_gravatar_url')
298 300 context.visual.default_renderer = rc_config.get(
299 301 'rhodecode_markup_renderer', 'rst')
300 302 context.visual.rhodecode_support_url = \
301 303 rc_config.get('rhodecode_support_url') or url('rhodecode_support')
302 304
303 305 context.pre_code = rc_config.get('rhodecode_pre_code')
304 306 context.post_code = rc_config.get('rhodecode_post_code')
305 307 context.rhodecode_name = rc_config.get('rhodecode_title')
306 308 context.default_encodings = aslist(config.get('default_encoding'), sep=',')
307 309 # if we have specified default_encoding in the request, it has more
308 310 # priority
309 311 if request.GET.get('default_encoding'):
310 312 context.default_encodings.insert(0, request.GET.get('default_encoding'))
311 313 context.clone_uri_tmpl = rc_config.get('rhodecode_clone_uri_tmpl')
312 314
313 315 # INI stored
314 316 context.labs_active = str2bool(
315 317 config.get('labs_settings_active', 'false'))
316 318 context.visual.allow_repo_location_change = str2bool(
317 319 config.get('allow_repo_location_change', True))
318 320 context.visual.allow_custom_hooks_settings = str2bool(
319 321 config.get('allow_custom_hooks_settings', True))
320 322 context.debug_style = str2bool(config.get('debug_style', False))
321 323
322 324 context.rhodecode_instanceid = config.get('instance_id')
323 325
324 326 # AppEnlight
325 327 context.appenlight_enabled = str2bool(config.get('appenlight', 'false'))
326 328 context.appenlight_api_public_key = config.get(
327 329 'appenlight.api_public_key', '')
328 330 context.appenlight_server_url = config.get('appenlight.server_url', '')
329 331
330 332 # JS template context
331 333 context.template_context = {
332 334 'repo_name': None,
333 335 'repo_type': None,
334 336 'repo_landing_commit': None,
335 337 'rhodecode_user': {
336 338 'username': None,
337 339 'email': None,
338 340 'notification_status': False
339 341 },
340 342 'visual': {
341 343 'default_renderer': None
342 344 },
343 345 'commit_data': {
344 346 'commit_id': None
345 347 },
346 348 'pull_request_data': {'pull_request_id': None},
347 349 'timeago': {
348 350 'refresh_time': 120 * 1000,
349 351 'cutoff_limit': 1000 * 60 * 60 * 24 * 7
350 352 },
351 353 'pylons_dispatch': {
352 354 # 'controller': request.environ['pylons.routes_dict']['controller'],
353 355 # 'action': request.environ['pylons.routes_dict']['action'],
354 356 },
355 357 'pyramid_dispatch': {
356 358
357 359 },
358 360 'extra': {'plugins': {}}
359 361 }
360 362 # END CONFIG VARS
361 363
362 364 # TODO: This dosn't work when called from pylons compatibility tween.
363 365 # Fix this and remove it from base controller.
364 366 # context.repo_name = get_repo_slug(request) # can be empty
365 367
366 368 context.csrf_token = auth.get_csrf_token()
367 369 context.backends = rhodecode.BACKENDS.keys()
368 370 context.backends.sort()
369 371 context.unread_notifications = NotificationModel().get_unread_cnt_for_user(
370 372 context.rhodecode_user.user_id)
371 373
372 374 context.pyramid_request = pyramid.threadlocal.get_current_request()
373 375
374 376
375 377 def get_auth_user(environ):
376 378 ip_addr = get_ip_addr(environ)
377 379 # make sure that we update permissions each time we call controller
378 380 _auth_token = (request.GET.get('auth_token', '') or
379 381 request.GET.get('api_key', ''))
380 382
381 383 if _auth_token:
382 384 # when using API_KEY we are sure user exists.
383 385 auth_user = AuthUser(api_key=_auth_token, ip_addr=ip_addr)
384 386 authenticated = False
385 387 else:
386 388 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
387 389 try:
388 390 auth_user = AuthUser(user_id=cookie_store.get('user_id', None),
389 391 ip_addr=ip_addr)
390 392 except UserCreationError as e:
391 393 h.flash(e, 'error')
392 394 # container auth or other auth functions that create users
393 395 # on the fly can throw this exception signaling that there's
394 396 # issue with user creation, explanation should be provided
395 397 # in Exception itself. We then create a simple blank
396 398 # AuthUser
397 399 auth_user = AuthUser(ip_addr=ip_addr)
398 400
399 401 if password_changed(auth_user, session):
400 402 session.invalidate()
401 403 cookie_store = CookieStoreWrapper(
402 404 session.get('rhodecode_user'))
403 405 auth_user = AuthUser(ip_addr=ip_addr)
404 406
405 407 authenticated = cookie_store.get('is_authenticated')
406 408
407 409 if not auth_user.is_authenticated and auth_user.is_user_object:
408 410 # user is not authenticated and not empty
409 411 auth_user.set_authenticated(authenticated)
410 412
411 413 return auth_user
412 414
413 415
414 416 class BaseController(WSGIController):
415 417
416 418 def __before__(self):
417 419 """
418 420 __before__ is called before controller methods and after __call__
419 421 """
420 422 # on each call propagate settings calls into global settings.
421 423 set_rhodecode_config(config)
422 424 attach_context_attributes(c, request)
423 425
424 426 # TODO: Remove this when fixed in attach_context_attributes()
425 427 c.repo_name = get_repo_slug(request) # can be empty
426 428
427 429 self.cut_off_limit_diff = safe_int(config.get('cut_off_limit_diff'))
428 430 self.cut_off_limit_file = safe_int(config.get('cut_off_limit_file'))
429 431 self.sa = meta.Session
430 432 self.scm_model = ScmModel(self.sa)
431 433
432 434 default_lang = c.language
433 435 user_lang = c.language
434 436 try:
435 437 user_obj = self._rhodecode_user.get_instance()
436 438 if user_obj:
437 439 user_lang = user_obj.user_data.get('language')
438 440 except Exception:
439 441 log.exception('Failed to fetch user language for user %s',
440 442 self._rhodecode_user)
441 443
442 444 if user_lang and user_lang != default_lang:
443 445 log.debug('set language to %s for user %s', user_lang,
444 446 self._rhodecode_user)
445 447 translation.set_lang(user_lang)
446 448
447 449 def _dispatch_redirect(self, with_url, environ, start_response):
448 450 resp = HTTPFound(with_url)
449 451 environ['SCRIPT_NAME'] = '' # handle prefix middleware
450 452 environ['PATH_INFO'] = with_url
451 453 return resp(environ, start_response)
452 454
453 455 def __call__(self, environ, start_response):
454 456 """Invoke the Controller"""
455 457 # WSGIController.__call__ dispatches to the Controller method
456 458 # the request is routed to. This routing information is
457 459 # available in environ['pylons.routes_dict']
458 460 from rhodecode.lib import helpers as h
459 461
460 462 # Provide the Pylons context to Pyramid's debugtoolbar if it asks
461 463 if environ.get('debugtoolbar.wants_pylons_context', False):
462 464 environ['debugtoolbar.pylons_context'] = c._current_obj()
463 465
464 466 _route_name = '.'.join([environ['pylons.routes_dict']['controller'],
465 467 environ['pylons.routes_dict']['action']])
466 468
467 469 self.rc_config = SettingsModel().get_all_settings(cache=True)
468 470 self.ip_addr = get_ip_addr(environ)
469 471
470 472 # The rhodecode auth user is looked up and passed through the
471 473 # environ by the pylons compatibility tween in pyramid.
472 474 # So we can just grab it from there.
473 475 auth_user = environ['rc_auth_user']
474 476
475 477 # set globals for auth user
476 478 request.user = auth_user
477 479 c.rhodecode_user = self._rhodecode_user = auth_user
478 480
479 481 log.info('IP: %s User: %s accessed %s [%s]' % (
480 482 self.ip_addr, auth_user, safe_unicode(get_access_path(environ)),
481 483 _route_name)
482 484 )
483 485
484 486 # TODO: Maybe this should be move to pyramid to cover all views.
485 487 # check user attributes for password change flag
486 488 user_obj = auth_user.get_instance()
487 489 if user_obj and user_obj.user_data.get('force_password_change'):
488 490 h.flash('You are required to change your password', 'warning',
489 491 ignore_duplicate=True)
490 492
491 493 skip_user_check_urls = [
492 494 'error.document', 'login.logout', 'login.index',
493 495 'admin/my_account.my_account_password',
494 496 'admin/my_account.my_account_password_update'
495 497 ]
496 498 if _route_name not in skip_user_check_urls:
497 499 return self._dispatch_redirect(
498 500 url('my_account_password'), environ, start_response)
499 501
500 502 return WSGIController.__call__(self, environ, start_response)
501 503
502 504
503 505 class BaseRepoController(BaseController):
504 506 """
505 507 Base class for controllers responsible for loading all needed data for
506 508 repository loaded items are
507 509
508 510 c.rhodecode_repo: instance of scm repository
509 511 c.rhodecode_db_repo: instance of db
510 512 c.repository_requirements_missing: shows that repository specific data
511 513 could not be displayed due to the missing requirements
512 514 c.repository_pull_requests: show number of open pull requests
513 515 """
514 516
515 517 def __before__(self):
516 518 super(BaseRepoController, self).__before__()
517 519 if c.repo_name: # extracted from routes
518 520 db_repo = Repository.get_by_repo_name(c.repo_name)
519 521 if not db_repo:
520 522 return
521 523
522 524 log.debug(
523 525 'Found repository in database %s with state `%s`',
524 526 safe_unicode(db_repo), safe_unicode(db_repo.repo_state))
525 527 route = getattr(request.environ.get('routes.route'), 'name', '')
526 528
527 529 # allow to delete repos that are somehow damages in filesystem
528 530 if route in ['delete_repo']:
529 531 return
530 532
531 533 if db_repo.repo_state in [Repository.STATE_PENDING]:
532 534 if route in ['repo_creating_home']:
533 535 return
534 536 check_url = url('repo_creating_home', repo_name=c.repo_name)
535 537 return redirect(check_url)
536 538
537 539 self.rhodecode_db_repo = db_repo
538 540
539 541 missing_requirements = False
540 542 try:
541 543 self.rhodecode_repo = self.rhodecode_db_repo.scm_instance()
542 544 except RepositoryRequirementError as e:
543 545 missing_requirements = True
544 546 self._handle_missing_requirements(e)
545 547
546 548 if self.rhodecode_repo is None and not missing_requirements:
547 549 log.error('%s this repository is present in database but it '
548 550 'cannot be created as an scm instance', c.repo_name)
549 551
550 552 h.flash(_(
551 553 "The repository at %(repo_name)s cannot be located.") %
552 554 {'repo_name': c.repo_name},
553 555 category='error', ignore_duplicate=True)
554 556 redirect(url('home'))
555 557
556 558 # update last change according to VCS data
557 559 if not missing_requirements:
558 560 commit = db_repo.get_commit(
559 561 pre_load=["author", "date", "message", "parents"])
560 562 db_repo.update_commit_cache(commit)
561 563
562 564 # Prepare context
563 565 c.rhodecode_db_repo = db_repo
564 566 c.rhodecode_repo = self.rhodecode_repo
565 567 c.repository_requirements_missing = missing_requirements
566 568
567 569 self._update_global_counters(self.scm_model, db_repo)
568 570
569 571 def _update_global_counters(self, scm_model, db_repo):
570 572 """
571 573 Base variables that are exposed to every page of repository
572 574 """
573 575 c.repository_pull_requests = scm_model.get_pull_requests(db_repo)
574 576
575 577 def _handle_missing_requirements(self, error):
576 578 self.rhodecode_repo = None
577 579 log.error(
578 580 'Requirements are missing for repository %s: %s',
579 581 c.repo_name, error.message)
580 582
581 583 summary_url = url('summary_home', repo_name=c.repo_name)
582 584 statistics_url = url('edit_repo_statistics', repo_name=c.repo_name)
583 585 settings_update_url = url('repo', repo_name=c.repo_name)
584 586 path = request.path
585 587 should_redirect = (
586 588 path not in (summary_url, settings_update_url)
587 589 and '/settings' not in path or path == statistics_url
588 590 )
589 591 if should_redirect:
590 592 redirect(summary_url)
@@ -1,505 +1,506 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2016 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 SimpleVCS middleware for handling protocol request (push/clone etc.)
23 23 It's implemented with basic auth function
24 24 """
25 25
26 26 import os
27 27 import logging
28 28 import importlib
29 29 import re
30 30 from functools import wraps
31 31
32 32 from paste.httpheaders import REMOTE_USER, AUTH_TYPE
33 33 from webob.exc import (
34 34 HTTPNotFound, HTTPForbidden, HTTPNotAcceptable, HTTPInternalServerError)
35 35
36 36 import rhodecode
37 37 from rhodecode.authentication.base import authenticate, VCS_TYPE
38 38 from rhodecode.lib.auth import AuthUser, HasPermissionAnyMiddleware
39 39 from rhodecode.lib.base import BasicAuth, get_ip_addr, vcs_operation_context
40 40 from rhodecode.lib.exceptions import (
41 41 HTTPLockedRC, HTTPRequirementError, UserCreationError,
42 42 NotAllowedToCreateUserError)
43 43 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
44 44 from rhodecode.lib.middleware import appenlight
45 45 from rhodecode.lib.middleware.utils import scm_app
46 46 from rhodecode.lib.utils import (
47 47 is_valid_repo, get_rhodecode_realm, get_rhodecode_base_path)
48 48 from rhodecode.lib.utils2 import safe_str, fix_PATH, str2bool
49 49 from rhodecode.lib.vcs.conf import settings as vcs_settings
50 50 from rhodecode.lib.vcs.backends import base
51 51 from rhodecode.model import meta
52 52 from rhodecode.model.db import User, Repository, PullRequest
53 53 from rhodecode.model.scm import ScmModel
54 54 from rhodecode.model.pull_request import PullRequestModel
55 55
56 56
57 57 log = logging.getLogger(__name__)
58 58
59 59
60 60 def initialize_generator(factory):
61 61 """
62 62 Initializes the returned generator by draining its first element.
63 63
64 64 This can be used to give a generator an initializer, which is the code
65 65 up to the first yield statement. This decorator enforces that the first
66 66 produced element has the value ``"__init__"`` to make its special
67 67 purpose very explicit in the using code.
68 68 """
69 69
70 70 @wraps(factory)
71 71 def wrapper(*args, **kwargs):
72 72 gen = factory(*args, **kwargs)
73 73 try:
74 74 init = gen.next()
75 75 except StopIteration:
76 76 raise ValueError('Generator must yield at least one element.')
77 77 if init != "__init__":
78 78 raise ValueError('First yielded element must be "__init__".')
79 79 return gen
80 80 return wrapper
81 81
82 82
83 83 class SimpleVCS(object):
84 84 """Common functionality for SCM HTTP handlers."""
85 85
86 86 SCM = 'unknown'
87 87
88 88 acl_repo_name = None
89 89 url_repo_name = None
90 90 vcs_repo_name = None
91 91
92 92 def __init__(self, application, config, registry):
93 93 self.registry = registry
94 94 self.application = application
95 95 self.config = config
96 96 # re-populated by specialized middleware
97 97 self.repo_vcs_config = base.Config()
98 98
99 99 # base path of repo locations
100 100 self.basepath = get_rhodecode_base_path()
101 101 # authenticate this VCS request using authfunc
102 102 auth_ret_code_detection = \
103 103 str2bool(self.config.get('auth_ret_code_detection', False))
104 104 self.authenticate = BasicAuth(
105 105 '', authenticate, registry, config.get('auth_ret_code'),
106 106 auth_ret_code_detection)
107 107 self.ip_addr = '0.0.0.0'
108 108
109 109 def set_repo_names(self, environ):
110 110 """
111 111 This will populate the attributes acl_repo_name, url_repo_name,
112 vcs_repo_name and pr_id on the current instance.
112 vcs_repo_name and is_shadow_repo on the current instance.
113 113 """
114 114 # TODO: martinb: Move to class or module scope.
115 115 # TODO: martinb: Check if we have to use re.UNICODE.
116 116 # TODO: martinb: Check which chars are allowed for repo/group names.
117 117 # These chars are excluded: '`?=[]\;\'"<>,/~!@#$%^&*()+{}|: '
118 118 # Code from: rhodecode/lib/utils.py:repo_name_slug()
119 119 pr_regex = re.compile(
120 120 '(?P<base_name>(?:[\w-]+)(?:/[\w-]+)*)/' # repo groups
121 121 '(?P<repo_name>[\w-]+)' # target repo name
122 122 '/pull-request/(?P<pr_id>\d+)/repository') # pr suffix
123 123
124 124 # Get url repo name from environment.
125 125 self.url_repo_name = self._get_repository_name(environ)
126 126
127 127 # Check if this is a request to a shadow repository. In case of a
128 128 # shadow repo set vcs_repo_name to the file system path pointing to the
129 129 # shadow repo. And set acl_repo_name to the pull request target repo
130 130 # because we use the target repo for permission checks. Otherwise all
131 131 # names are equal.
132 132 match = pr_regex.match(self.url_repo_name)
133 133 if match:
134 134 # Get pull request instance.
135 135 match_dict = match.groupdict()
136 136 pr_id = match_dict['pr_id']
137 137 pull_request = PullRequest.get(pr_id)
138 138
139 139 # Get file system path to shadow repository.
140 140 workspace_id = PullRequestModel()._workspace_id(pull_request)
141 141 target_vcs = pull_request.target_repo.scm_instance()
142 142 vcs_repo_name = target_vcs._get_shadow_repository_path(
143 143 workspace_id)
144 144
145 145 # Store names for later usage.
146 self.pr_id = pr_id
147 146 self.vcs_repo_name = vcs_repo_name
148 147 self.acl_repo_name = pull_request.target_repo.repo_name
148 self.is_shadow_repo = True
149 149 else:
150 150 # All names are equal for normal (non shadow) repositories.
151 151 self.acl_repo_name = self.url_repo_name
152 152 self.vcs_repo_name = self.url_repo_name
153 self.pr_id = None
153 self.is_shadow_repo = False
154 154
155 155 @property
156 156 def scm_app(self):
157 157 custom_implementation = self.config.get('vcs.scm_app_implementation')
158 158 if custom_implementation and custom_implementation != 'pyro4':
159 159 log.info(
160 160 "Using custom implementation of scm_app: %s",
161 161 custom_implementation)
162 162 scm_app_impl = importlib.import_module(custom_implementation)
163 163 else:
164 164 scm_app_impl = scm_app
165 165 return scm_app_impl
166 166
167 167 def _get_by_id(self, repo_name):
168 168 """
169 169 Gets a special pattern _<ID> from clone url and tries to replace it
170 170 with a repository_name for support of _<ID> non changeable urls
171 171 """
172 172
173 173 data = repo_name.split('/')
174 174 if len(data) >= 2:
175 175 from rhodecode.model.repo import RepoModel
176 176 by_id_match = RepoModel().get_repo_by_id(repo_name)
177 177 if by_id_match:
178 178 data[1] = by_id_match.repo_name
179 179
180 180 return safe_str('/'.join(data))
181 181
182 182 def _invalidate_cache(self, repo_name):
183 183 """
184 184 Set's cache for this repository for invalidation on next access
185 185
186 186 :param repo_name: full repo name, also a cache key
187 187 """
188 188 ScmModel().mark_for_invalidation(repo_name)
189 189
190 190 def is_valid_and_existing_repo(self, repo_name, base_path, scm_type):
191 191 db_repo = Repository.get_by_repo_name(repo_name)
192 192 if not db_repo:
193 193 log.debug('Repository `%s` not found inside the database.',
194 194 repo_name)
195 195 return False
196 196
197 197 if db_repo.repo_type != scm_type:
198 198 log.warning(
199 199 'Repository `%s` have incorrect scm_type, expected %s got %s',
200 200 repo_name, db_repo.repo_type, scm_type)
201 201 return False
202 202
203 203 return is_valid_repo(repo_name, base_path, expect_scm=scm_type)
204 204
205 205 def valid_and_active_user(self, user):
206 206 """
207 207 Checks if that user is not empty, and if it's actually object it checks
208 208 if he's active.
209 209
210 210 :param user: user object or None
211 211 :return: boolean
212 212 """
213 213 if user is None:
214 214 return False
215 215
216 216 elif user.active:
217 217 return True
218 218
219 219 return False
220 220
221 221 def _check_permission(self, action, user, repo_name, ip_addr=None):
222 222 """
223 223 Checks permissions using action (push/pull) user and repository
224 224 name
225 225
226 226 :param action: push or pull action
227 227 :param user: user instance
228 228 :param repo_name: repository name
229 229 """
230 230 # check IP
231 231 inherit = user.inherit_default_permissions
232 232 ip_allowed = AuthUser.check_ip_allowed(user.user_id, ip_addr,
233 233 inherit_from_default=inherit)
234 234 if ip_allowed:
235 235 log.info('Access for IP:%s allowed', ip_addr)
236 236 else:
237 237 return False
238 238
239 239 if action == 'push':
240 240 if not HasPermissionAnyMiddleware('repository.write',
241 241 'repository.admin')(user,
242 242 repo_name):
243 243 return False
244 244
245 245 else:
246 246 # any other action need at least read permission
247 247 if not HasPermissionAnyMiddleware('repository.read',
248 248 'repository.write',
249 249 'repository.admin')(user,
250 250 repo_name):
251 251 return False
252 252
253 253 return True
254 254
255 255 def _check_ssl(self, environ, start_response):
256 256 """
257 257 Checks the SSL check flag and returns False if SSL is not present
258 258 and required True otherwise
259 259 """
260 260 org_proto = environ['wsgi._org_proto']
261 261 # check if we have SSL required ! if not it's a bad request !
262 262 require_ssl = str2bool(self.repo_vcs_config.get('web', 'push_ssl'))
263 263 if require_ssl and org_proto == 'http':
264 264 log.debug('proto is %s and SSL is required BAD REQUEST !',
265 265 org_proto)
266 266 return False
267 267 return True
268 268
269 269 def __call__(self, environ, start_response):
270 270 try:
271 271 return self._handle_request(environ, start_response)
272 272 except Exception:
273 273 log.exception("Exception while handling request")
274 274 appenlight.track_exception(environ)
275 275 return HTTPInternalServerError()(environ, start_response)
276 276 finally:
277 277 meta.Session.remove()
278 278
279 279 def _handle_request(self, environ, start_response):
280 280
281 281 if not self._check_ssl(environ, start_response):
282 282 reason = ('SSL required, while RhodeCode was unable '
283 283 'to detect this as SSL request')
284 284 log.debug('User not allowed to proceed, %s', reason)
285 285 return HTTPNotAcceptable(reason)(environ, start_response)
286 286
287 287 if not self.url_repo_name:
288 288 log.warning('Repository name is empty: %s', self.url_repo_name)
289 289 # failed to get repo name, we fail now
290 290 return HTTPNotFound()(environ, start_response)
291 291 log.debug('Extracted repo name is %s', self.url_repo_name)
292 292
293 293 ip_addr = get_ip_addr(environ)
294 294 username = None
295 295
296 296 # skip passing error to error controller
297 297 environ['pylons.status_code_redirect'] = True
298 298
299 299 # ======================================================================
300 300 # GET ACTION PULL or PUSH
301 301 # ======================================================================
302 302 action = self._get_action(environ)
303 303
304 304 # ======================================================================
305 305 # Check if this is a request to a shadow repository of a pull request.
306 306 # In this case only pull action is allowed.
307 307 # ======================================================================
308 if self.pr_id is not None and action != 'pull':
308 if self.is_shadow_repo and action != 'pull':
309 309 reason = 'Only pull action is allowed for shadow repositories.'
310 310 log.debug('User not allowed to proceed, %s', reason)
311 311 return HTTPNotAcceptable(reason)(environ, start_response)
312 312
313 313 # ======================================================================
314 314 # CHECK ANONYMOUS PERMISSION
315 315 # ======================================================================
316 316 if action in ['pull', 'push']:
317 317 anonymous_user = User.get_default_user()
318 318 username = anonymous_user.username
319 319 if anonymous_user.active:
320 320 # ONLY check permissions if the user is activated
321 321 anonymous_perm = self._check_permission(
322 322 action, anonymous_user, self.acl_repo_name, ip_addr)
323 323 else:
324 324 anonymous_perm = False
325 325
326 326 if not anonymous_user.active or not anonymous_perm:
327 327 if not anonymous_user.active:
328 328 log.debug('Anonymous access is disabled, running '
329 329 'authentication')
330 330
331 331 if not anonymous_perm:
332 332 log.debug('Not enough credentials to access this '
333 333 'repository as anonymous user')
334 334
335 335 username = None
336 336 # ==============================================================
337 337 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
338 338 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
339 339 # ==============================================================
340 340
341 341 # try to auth based on environ, container auth methods
342 342 log.debug('Running PRE-AUTH for container based authentication')
343 343 pre_auth = authenticate(
344 344 '', '', environ, VCS_TYPE, registry=self.registry)
345 345 if pre_auth and pre_auth.get('username'):
346 346 username = pre_auth['username']
347 347 log.debug('PRE-AUTH got %s as username', username)
348 348
349 349 # If not authenticated by the container, running basic auth
350 350 if not username:
351 351 self.authenticate.realm = get_rhodecode_realm()
352 352
353 353 try:
354 354 result = self.authenticate(environ)
355 355 except (UserCreationError, NotAllowedToCreateUserError) as e:
356 356 log.error(e)
357 357 reason = safe_str(e)
358 358 return HTTPNotAcceptable(reason)(environ, start_response)
359 359
360 360 if isinstance(result, str):
361 361 AUTH_TYPE.update(environ, 'basic')
362 362 REMOTE_USER.update(environ, result)
363 363 username = result
364 364 else:
365 365 return result.wsgi_application(environ, start_response)
366 366
367 367 # ==============================================================
368 368 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
369 369 # ==============================================================
370 370 user = User.get_by_username(username)
371 371 if not self.valid_and_active_user(user):
372 372 return HTTPForbidden()(environ, start_response)
373 373 username = user.username
374 374 user.update_lastactivity()
375 375 meta.Session().commit()
376 376
377 377 # check user attributes for password change flag
378 378 user_obj = user
379 379 if user_obj and user_obj.username != User.DEFAULT_USER and \
380 380 user_obj.user_data.get('force_password_change'):
381 381 reason = 'password change required'
382 382 log.debug('User not allowed to authenticate, %s', reason)
383 383 return HTTPNotAcceptable(reason)(environ, start_response)
384 384
385 385 # check permissions for this repository
386 386 perm = self._check_permission(
387 387 action, user, self.acl_repo_name, ip_addr)
388 388 if not perm:
389 389 return HTTPForbidden()(environ, start_response)
390 390
391 391 # extras are injected into UI object and later available
392 392 # in hooks executed by rhodecode
393 393 check_locking = _should_check_locking(environ.get('QUERY_STRING'))
394 394 extras = vcs_operation_context(
395 395 environ, repo_name=self.acl_repo_name, username=username,
396 action=action, scm=self.SCM,
397 check_locking=check_locking)
396 action=action, scm=self.SCM, check_locking=check_locking,
397 is_shadow_repo=self.is_shadow_repo
398 )
398 399
399 400 # ======================================================================
400 401 # REQUEST HANDLING
401 402 # ======================================================================
402 403 str_repo_name = safe_str(self.url_repo_name)
403 404 repo_path = os.path.join(
404 405 safe_str(self.basepath), safe_str(self.vcs_repo_name))
405 406 log.debug('Repository path is %s', repo_path)
406 407
407 408 fix_PATH()
408 409
409 410 log.info(
410 411 '%s action on %s repo "%s" by "%s" from %s',
411 412 action, self.SCM, str_repo_name, safe_str(username), ip_addr)
412 413
413 414 return self._generate_vcs_response(
414 415 environ, start_response, repo_path, self.url_repo_name, extras, action)
415 416
416 417 @initialize_generator
417 418 def _generate_vcs_response(
418 419 self, environ, start_response, repo_path, repo_name, extras,
419 420 action):
420 421 """
421 422 Returns a generator for the response content.
422 423
423 424 This method is implemented as a generator, so that it can trigger
424 425 the cache validation after all content sent back to the client. It
425 426 also handles the locking exceptions which will be triggered when
426 427 the first chunk is produced by the underlying WSGI application.
427 428 """
428 429 callback_daemon, extras = self._prepare_callback_daemon(extras)
429 430 config = self._create_config(extras, self.acl_repo_name)
430 431 log.debug('HOOKS extras is %s', extras)
431 432 app = self._create_wsgi_app(repo_path, repo_name, config)
432 433
433 434 try:
434 435 with callback_daemon:
435 436 try:
436 437 response = app(environ, start_response)
437 438 finally:
438 439 # This statement works together with the decorator
439 440 # "initialize_generator" above. The decorator ensures that
440 441 # we hit the first yield statement before the generator is
441 442 # returned back to the WSGI server. This is needed to
442 443 # ensure that the call to "app" above triggers the
443 444 # needed callback to "start_response" before the
444 445 # generator is actually used.
445 446 yield "__init__"
446 447
447 448 for chunk in response:
448 449 yield chunk
449 450 except Exception as exc:
450 451 # TODO: johbo: Improve "translating" back the exception.
451 452 if getattr(exc, '_vcs_kind', None) == 'repo_locked':
452 453 exc = HTTPLockedRC(*exc.args)
453 454 _code = rhodecode.CONFIG.get('lock_ret_code')
454 455 log.debug('Repository LOCKED ret code %s!', (_code,))
455 456 elif getattr(exc, '_vcs_kind', None) == 'requirement':
456 457 log.debug(
457 458 'Repository requires features unknown to this Mercurial')
458 459 exc = HTTPRequirementError(*exc.args)
459 460 else:
460 461 raise
461 462
462 463 for chunk in exc(environ, start_response):
463 464 yield chunk
464 465 finally:
465 466 # invalidate cache on push
466 467 try:
467 468 if action == 'push':
468 469 self._invalidate_cache(repo_name)
469 470 finally:
470 471 meta.Session.remove()
471 472
472 473 def _get_repository_name(self, environ):
473 474 """Get repository name out of the environmnent
474 475
475 476 :param environ: WSGI environment
476 477 """
477 478 raise NotImplementedError()
478 479
479 480 def _get_action(self, environ):
480 481 """Map request commands into a pull or push command.
481 482
482 483 :param environ: WSGI environment
483 484 """
484 485 raise NotImplementedError()
485 486
486 487 def _create_wsgi_app(self, repo_path, repo_name, config):
487 488 """Return the WSGI app that will finally handle the request."""
488 489 raise NotImplementedError()
489 490
490 491 def _create_config(self, extras, repo_name):
491 492 """Create a Pyro safe config representation."""
492 493 raise NotImplementedError()
493 494
494 495 def _prepare_callback_daemon(self, extras):
495 496 return prepare_callback_daemon(
496 497 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
497 498 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
498 499
499 500
500 501 def _should_check_locking(query_string):
501 502 # this is kind of hacky, but due to how mercurial handles client-server
502 503 # server see all operation on commit; bookmarks, phases and
503 504 # obsolescence marker in different transaction, we don't want to check
504 505 # locking on those
505 506 return query_string not in ['cmd=listkeys']
General Comments 0
You need to be logged in to leave comments. Login now