base.py
604 lines
| 22.0 KiB
| text/x-python
|
PythonLexer
r1 | # -*- coding: utf-8 -*- | |||
r1271 | # Copyright (C) 2010-2017 RhodeCode GmbH | |||
r1 | # | |||
# This program is free software: you can redistribute it and/or modify | ||||
# it under the terms of the GNU Affero General Public License, version 3 | ||||
# (only), as published by the Free Software Foundation. | ||||
# | ||||
# This program is distributed in the hope that it will be useful, | ||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
# GNU General Public License for more details. | ||||
# | ||||
# You should have received a copy of the GNU Affero General Public License | ||||
# along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||
# | ||||
# This program is dual-licensed. If you wish to learn more about the | ||||
# RhodeCode Enterprise Edition, including its added features, Support services, | ||||
# and proprietary license terms, please see https://rhodecode.com/licenses/ | ||||
""" | ||||
The base Controller API | ||||
Provides the BaseController class for subclassing. And usage in different | ||||
controllers | ||||
""" | ||||
import logging | ||||
import socket | ||||
import ipaddress | ||||
r765 | import pyramid.threadlocal | |||
r1 | ||||
from paste.auth.basic import AuthBasicAuthenticator | ||||
from paste.httpexceptions import HTTPUnauthorized, HTTPForbidden, get_exception | ||||
from paste.httpheaders import WWW_AUTHENTICATE, AUTHORIZATION | ||||
from pylons import config, tmpl_context as c, request, session, url | ||||
from pylons.controllers import WSGIController | ||||
from pylons.controllers.util import redirect | ||||
from pylons.i18n import translation | ||||
# marcink: don't remove this import | ||||
from pylons.templating import render_mako as render # noqa | ||||
from pylons.i18n.translation import _ | ||||
from webob.exc import HTTPFound | ||||
import rhodecode | ||||
from rhodecode.authentication.base import VCS_TYPE | ||||
from rhodecode.lib import auth, utils2 | ||||
from rhodecode.lib import helpers as h | ||||
from rhodecode.lib.auth import AuthUser, CookieStoreWrapper | ||||
from rhodecode.lib.exceptions import UserCreationError | ||||
from rhodecode.lib.utils import ( | ||||
get_repo_slug, set_rhodecode_config, password_changed, | ||||
get_enabled_hook_classes) | ||||
from rhodecode.lib.utils2 import ( | ||||
str2bool, safe_unicode, AttributeDict, safe_int, md5, aslist) | ||||
from rhodecode.lib.vcs.exceptions import RepositoryRequirementError | ||||
from rhodecode.model import meta | ||||
from rhodecode.model.db import Repository, User | ||||
from rhodecode.model.notification import NotificationModel | ||||
from rhodecode.model.scm import ScmModel | ||||
from rhodecode.model.settings import VcsSettingsModel, SettingsModel | ||||
log = logging.getLogger(__name__) | ||||
def _filter_proxy(ip): | ||||
""" | ||||
Passed in IP addresses in HEADERS can be in a special format of multiple | ||||
ips. Those comma separated IPs are passed from various proxies in the | ||||
chain of request processing. The left-most being the original client. | ||||
We only care about the first IP which came from the org. client. | ||||
:param ip: ip string from headers | ||||
""" | ||||
if ',' in ip: | ||||
_ips = ip.split(',') | ||||
_first_ip = _ips[0].strip() | ||||
log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip) | ||||
return _first_ip | ||||
return ip | ||||
def _filter_port(ip): | ||||
""" | ||||
Removes a port from ip, there are 4 main cases to handle here. | ||||
- ipv4 eg. 127.0.0.1 | ||||
- ipv6 eg. ::1 | ||||
- ipv4+port eg. 127.0.0.1:8080 | ||||
- ipv6+port eg. [::1]:8080 | ||||
:param ip: | ||||
""" | ||||
def is_ipv6(ip_addr): | ||||
if hasattr(socket, 'inet_pton'): | ||||
try: | ||||
socket.inet_pton(socket.AF_INET6, ip_addr) | ||||
except socket.error: | ||||
return False | ||||
else: | ||||
# fallback to ipaddress | ||||
try: | ||||
ipaddress.IPv6Address(ip_addr) | ||||
except Exception: | ||||
return False | ||||
return True | ||||
if ':' not in ip: # must be ipv4 pure ip | ||||
return ip | ||||
if '[' in ip and ']' in ip: # ipv6 with port | ||||
return ip.split(']')[0][1:].lower() | ||||
# must be ipv6 or ipv4 with port | ||||
if is_ipv6(ip): | ||||
return ip | ||||
else: | ||||
ip, _port = ip.split(':')[:2] # means ipv4+port | ||||
return ip | ||||
def get_ip_addr(environ): | ||||
proxy_key = 'HTTP_X_REAL_IP' | ||||
proxy_key2 = 'HTTP_X_FORWARDED_FOR' | ||||
def_key = 'REMOTE_ADDR' | ||||
_filters = lambda x: _filter_port(_filter_proxy(x)) | ||||
ip = environ.get(proxy_key) | ||||
if ip: | ||||
return _filters(ip) | ||||
ip = environ.get(proxy_key2) | ||||
if ip: | ||||
return _filters(ip) | ||||
ip = environ.get(def_key, '0.0.0.0') | ||||
return _filters(ip) | ||||
def get_server_ip_addr(environ, log_errors=True): | ||||
hostname = environ.get('SERVER_NAME') | ||||
try: | ||||
return socket.gethostbyname(hostname) | ||||
except Exception as e: | ||||
if log_errors: | ||||
# in some cases this lookup is not possible, and we don't want to | ||||
# make it an exception in logs | ||||
log.exception('Could not retrieve server ip address: %s', e) | ||||
return hostname | ||||
def get_server_port(environ): | ||||
return environ.get('SERVER_PORT') | ||||
def get_access_path(environ): | ||||
path = environ.get('PATH_INFO') | ||||
org_req = environ.get('pylons.original_request') | ||||
if org_req: | ||||
path = org_req.environ.get('PATH_INFO') | ||||
return path | ||||
def vcs_operation_context( | ||||
|
r899 | environ, repo_name, username, action, scm, check_locking=True, | ||
is_shadow_repo=False): | ||||
r1 | """ | |||
Generate the context for a vcs operation, e.g. push or pull. | ||||
This context is passed over the layers so that hooks triggered by the | ||||
vcs operation know details like the user, the user's IP address etc. | ||||
:param check_locking: Allows to switch of the computation of the locking | ||||
data. This serves mainly the need of the simplevcs middleware to be | ||||
able to disable this for certain operations. | ||||
""" | ||||
# Tri-state value: False: unlock, None: nothing, True: lock | ||||
make_lock = None | ||||
locked_by = [None, None, None] | ||||
is_anonymous = username == User.DEFAULT_USER | ||||
if not is_anonymous and check_locking: | ||||
log.debug('Checking locking on repository "%s"', repo_name) | ||||
user = User.get_by_username(username) | ||||
repo = Repository.get_by_repo_name(repo_name) | ||||
make_lock, __, locked_by = repo.get_locking_state( | ||||
action, user.user_id) | ||||
settings_model = VcsSettingsModel(repo=repo_name) | ||||
ui_settings = settings_model.get_ui_settings() | ||||
extras = { | ||||
'ip': get_ip_addr(environ), | ||||
'username': username, | ||||
'action': action, | ||||
'repository': repo_name, | ||||
'scm': scm, | ||||
'config': rhodecode.CONFIG['__file__'], | ||||
'make_lock': make_lock, | ||||
'locked_by': locked_by, | ||||
'server_url': utils2.get_server_url(environ), | ||||
'hooks': get_enabled_hook_classes(ui_settings), | ||||
|
r899 | 'is_shadow_repo': is_shadow_repo, | ||
r1 | } | |||
return extras | ||||
class BasicAuth(AuthBasicAuthenticator): | ||||
|
r591 | def __init__(self, realm, authfunc, registry, auth_http_code=None, | ||
r1 | initial_call_detection=False): | |||
self.realm = realm | ||||
self.initial_call = initial_call_detection | ||||
self.authfunc = authfunc | ||||
|
r591 | self.registry = registry | ||
r1 | self._rc_auth_http_code = auth_http_code | |||
def _get_response_from_code(self, http_code): | ||||
try: | ||||
return get_exception(safe_int(http_code)) | ||||
except Exception: | ||||
log.exception('Failed to fetch response for code %s' % http_code) | ||||
return HTTPForbidden | ||||
def build_authentication(self): | ||||
head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm) | ||||
if self._rc_auth_http_code and not self.initial_call: | ||||
# return alternative HTTP code if alternative http return code | ||||
# is specified in RhodeCode config, but ONLY if it's not the | ||||
# FIRST call | ||||
custom_response_klass = self._get_response_from_code( | ||||
self._rc_auth_http_code) | ||||
return custom_response_klass(headers=head) | ||||
return HTTPUnauthorized(headers=head) | ||||
def authenticate(self, environ): | ||||
authorization = AUTHORIZATION(environ) | ||||
if not authorization: | ||||
return self.build_authentication() | ||||
(authmeth, auth) = authorization.split(' ', 1) | ||||
if 'basic' != authmeth.lower(): | ||||
return self.build_authentication() | ||||
auth = auth.strip().decode('base64') | ||||
_parts = auth.split(':', 1) | ||||
if len(_parts) == 2: | ||||
username, password = _parts | ||||
if self.authfunc( | ||||
|
r591 | username, password, environ, VCS_TYPE, | ||
registry=self.registry): | ||||
r1 | return username | |||
if username and password: | ||||
# we mark that we actually executed authentication once, at | ||||
# that point we can use the alternative auth code | ||||
self.initial_call = False | ||||
return self.build_authentication() | ||||
__call__ = authenticate | ||||
r400 | def attach_context_attributes(context, request): | |||
""" | ||||
Attach variables into template context called `c`, please note that | ||||
request could be pylons or pyramid request in here. | ||||
""" | ||||
r260 | rc_config = SettingsModel().get_all_settings(cache=True) | |||
r1 | ||||
context.rhodecode_version = rhodecode.__version__ | ||||
context.rhodecode_edition = config.get('rhodecode.edition') | ||||
# unique secret + version does not leak the version but keep consistency | ||||
context.rhodecode_version_hash = md5( | ||||
config.get('beaker.session.secret', '') + | ||||
rhodecode.__version__)[:8] | ||||
# Default language set for the incoming request | ||||
context.language = translation.get_lang()[0] | ||||
# Visual options | ||||
context.visual = AttributeDict({}) | ||||
r754 | # DB stored Visual Items | |||
r1 | context.visual.show_public_icon = str2bool( | |||
rc_config.get('rhodecode_show_public_icon')) | ||||
context.visual.show_private_icon = str2bool( | ||||
rc_config.get('rhodecode_show_private_icon')) | ||||
context.visual.stylify_metatags = str2bool( | ||||
rc_config.get('rhodecode_stylify_metatags')) | ||||
context.visual.dashboard_items = safe_int( | ||||
rc_config.get('rhodecode_dashboard_items', 100)) | ||||
context.visual.admin_grid_items = safe_int( | ||||
rc_config.get('rhodecode_admin_grid_items', 100)) | ||||
context.visual.repository_fields = str2bool( | ||||
rc_config.get('rhodecode_repository_fields')) | ||||
context.visual.show_version = str2bool( | ||||
rc_config.get('rhodecode_show_version')) | ||||
context.visual.use_gravatar = str2bool( | ||||
rc_config.get('rhodecode_use_gravatar')) | ||||
context.visual.gravatar_url = rc_config.get('rhodecode_gravatar_url') | ||||
context.visual.default_renderer = rc_config.get( | ||||
'rhodecode_markup_renderer', 'rst') | ||||
context.visual.rhodecode_support_url = \ | ||||
rc_config.get('rhodecode_support_url') or url('rhodecode_support') | ||||
context.pre_code = rc_config.get('rhodecode_pre_code') | ||||
context.post_code = rc_config.get('rhodecode_post_code') | ||||
context.rhodecode_name = rc_config.get('rhodecode_title') | ||||
context.default_encodings = aslist(config.get('default_encoding'), sep=',') | ||||
# if we have specified default_encoding in the request, it has more | ||||
# priority | ||||
if request.GET.get('default_encoding'): | ||||
context.default_encodings.insert(0, request.GET.get('default_encoding')) | ||||
context.clone_uri_tmpl = rc_config.get('rhodecode_clone_uri_tmpl') | ||||
# INI stored | ||||
context.labs_active = str2bool( | ||||
config.get('labs_settings_active', 'false')) | ||||
context.visual.allow_repo_location_change = str2bool( | ||||
config.get('allow_repo_location_change', True)) | ||||
context.visual.allow_custom_hooks_settings = str2bool( | ||||
config.get('allow_custom_hooks_settings', True)) | ||||
context.debug_style = str2bool(config.get('debug_style', False)) | ||||
context.rhodecode_instanceid = config.get('instance_id') | ||||
# AppEnlight | ||||
context.appenlight_enabled = str2bool(config.get('appenlight', 'false')) | ||||
context.appenlight_api_public_key = config.get( | ||||
'appenlight.api_public_key', '') | ||||
context.appenlight_server_url = config.get('appenlight.server_url', '') | ||||
r400 | # JS template context | |||
context.template_context = { | ||||
'repo_name': None, | ||||
'repo_type': None, | ||||
'repo_landing_commit': None, | ||||
'rhodecode_user': { | ||||
'username': None, | ||||
'email': None, | ||||
r526 | 'notification_status': False | |||
r400 | }, | |||
'visual': { | ||||
'default_renderer': None | ||||
}, | ||||
'commit_data': { | ||||
'commit_id': None | ||||
}, | ||||
'pull_request_data': {'pull_request_id': None}, | ||||
'timeago': { | ||||
'refresh_time': 120 * 1000, | ||||
'cutoff_limit': 1000 * 60 * 60 * 24 * 7 | ||||
}, | ||||
'pylons_dispatch': { | ||||
# 'controller': request.environ['pylons.routes_dict']['controller'], | ||||
# 'action': request.environ['pylons.routes_dict']['action'], | ||||
}, | ||||
'pyramid_dispatch': { | ||||
}, | ||||
'extra': {'plugins': {}} | ||||
} | ||||
r1 | # END CONFIG VARS | |||
# TODO: This dosn't work when called from pylons compatibility tween. | ||||
# Fix this and remove it from base controller. | ||||
# context.repo_name = get_repo_slug(request) # can be empty | ||||
r1137 | diffmode = 'sideside' | |||
if request.GET.get('diffmode'): | ||||
if request.GET['diffmode'] == 'unified': | ||||
diffmode = 'unified' | ||||
elif request.session.get('diffmode'): | ||||
diffmode = request.session['diffmode'] | ||||
context.diffmode = diffmode | ||||
if request.session.get('diffmode') != diffmode: | ||||
request.session['diffmode'] = diffmode | ||||
r1 | context.csrf_token = auth.get_csrf_token() | |||
context.backends = rhodecode.BACKENDS.keys() | ||||
context.backends.sort() | ||||
context.unread_notifications = NotificationModel().get_unread_cnt_for_user( | ||||
context.rhodecode_user.user_id) | ||||
r765 | context.pyramid_request = pyramid.threadlocal.get_current_request() | |||
r1 | ||||
def get_auth_user(environ): | ||||
ip_addr = get_ip_addr(environ) | ||||
# make sure that we update permissions each time we call controller | ||||
_auth_token = (request.GET.get('auth_token', '') or | ||||
request.GET.get('api_key', '')) | ||||
if _auth_token: | ||||
r1300 | # when using API_KEY we assume user exists, and | |||
# doesn't need auth based on cookies. | ||||
r1 | auth_user = AuthUser(api_key=_auth_token, ip_addr=ip_addr) | |||
authenticated = False | ||||
else: | ||||
cookie_store = CookieStoreWrapper(session.get('rhodecode_user')) | ||||
try: | ||||
auth_user = AuthUser(user_id=cookie_store.get('user_id', None), | ||||
ip_addr=ip_addr) | ||||
except UserCreationError as e: | ||||
h.flash(e, 'error') | ||||
# container auth or other auth functions that create users | ||||
# on the fly can throw this exception signaling that there's | ||||
# issue with user creation, explanation should be provided | ||||
# in Exception itself. We then create a simple blank | ||||
# AuthUser | ||||
auth_user = AuthUser(ip_addr=ip_addr) | ||||
if password_changed(auth_user, session): | ||||
session.invalidate() | ||||
r1300 | cookie_store = CookieStoreWrapper(session.get('rhodecode_user')) | |||
r1 | auth_user = AuthUser(ip_addr=ip_addr) | |||
authenticated = cookie_store.get('is_authenticated') | ||||
if not auth_user.is_authenticated and auth_user.is_user_object: | ||||
# user is not authenticated and not empty | ||||
auth_user.set_authenticated(authenticated) | ||||
return auth_user | ||||
class BaseController(WSGIController): | ||||
def __before__(self): | ||||
""" | ||||
__before__ is called before controller methods and after __call__ | ||||
""" | ||||
# on each call propagate settings calls into global settings. | ||||
set_rhodecode_config(config) | ||||
r400 | attach_context_attributes(c, request) | |||
r1 | ||||
# TODO: Remove this when fixed in attach_context_attributes() | ||||
c.repo_name = get_repo_slug(request) # can be empty | ||||
self.cut_off_limit_diff = safe_int(config.get('cut_off_limit_diff')) | ||||
self.cut_off_limit_file = safe_int(config.get('cut_off_limit_file')) | ||||
self.sa = meta.Session | ||||
self.scm_model = ScmModel(self.sa) | ||||
default_lang = c.language | ||||
user_lang = c.language | ||||
try: | ||||
user_obj = self._rhodecode_user.get_instance() | ||||
if user_obj: | ||||
user_lang = user_obj.user_data.get('language') | ||||
except Exception: | ||||
log.exception('Failed to fetch user language for user %s', | ||||
self._rhodecode_user) | ||||
if user_lang and user_lang != default_lang: | ||||
log.debug('set language to %s for user %s', user_lang, | ||||
self._rhodecode_user) | ||||
translation.set_lang(user_lang) | ||||
def _dispatch_redirect(self, with_url, environ, start_response): | ||||
resp = HTTPFound(with_url) | ||||
environ['SCRIPT_NAME'] = '' # handle prefix middleware | ||||
environ['PATH_INFO'] = with_url | ||||
return resp(environ, start_response) | ||||
def __call__(self, environ, start_response): | ||||
"""Invoke the Controller""" | ||||
# WSGIController.__call__ dispatches to the Controller method | ||||
# the request is routed to. This routing information is | ||||
# available in environ['pylons.routes_dict'] | ||||
from rhodecode.lib import helpers as h | ||||
# Provide the Pylons context to Pyramid's debugtoolbar if it asks | ||||
if environ.get('debugtoolbar.wants_pylons_context', False): | ||||
environ['debugtoolbar.pylons_context'] = c._current_obj() | ||||
_route_name = '.'.join([environ['pylons.routes_dict']['controller'], | ||||
environ['pylons.routes_dict']['action']]) | ||||
r260 | self.rc_config = SettingsModel().get_all_settings(cache=True) | |||
r1 | self.ip_addr = get_ip_addr(environ) | |||
# The rhodecode auth user is looked up and passed through the | ||||
# environ by the pylons compatibility tween in pyramid. | ||||
# So we can just grab it from there. | ||||
auth_user = environ['rc_auth_user'] | ||||
# set globals for auth user | ||||
request.user = auth_user | ||||
c.rhodecode_user = self._rhodecode_user = auth_user | ||||
log.info('IP: %s User: %s accessed %s [%s]' % ( | ||||
self.ip_addr, auth_user, safe_unicode(get_access_path(environ)), | ||||
_route_name) | ||||
) | ||||
# TODO: Maybe this should be move to pyramid to cover all views. | ||||
# check user attributes for password change flag | ||||
user_obj = auth_user.get_instance() | ||||
if user_obj and user_obj.user_data.get('force_password_change'): | ||||
h.flash('You are required to change your password', 'warning', | ||||
ignore_duplicate=True) | ||||
skip_user_check_urls = [ | ||||
'error.document', 'login.logout', 'login.index', | ||||
'admin/my_account.my_account_password', | ||||
'admin/my_account.my_account_password_update' | ||||
] | ||||
if _route_name not in skip_user_check_urls: | ||||
return self._dispatch_redirect( | ||||
url('my_account_password'), environ, start_response) | ||||
return WSGIController.__call__(self, environ, start_response) | ||||
class BaseRepoController(BaseController): | ||||
""" | ||||
Base class for controllers responsible for loading all needed data for | ||||
repository loaded items are | ||||
c.rhodecode_repo: instance of scm repository | ||||
c.rhodecode_db_repo: instance of db | ||||
c.repository_requirements_missing: shows that repository specific data | ||||
could not be displayed due to the missing requirements | ||||
c.repository_pull_requests: show number of open pull requests | ||||
""" | ||||
def __before__(self): | ||||
super(BaseRepoController, self).__before__() | ||||
if c.repo_name: # extracted from routes | ||||
db_repo = Repository.get_by_repo_name(c.repo_name) | ||||
if not db_repo: | ||||
return | ||||
log.debug( | ||||
'Found repository in database %s with state `%s`', | ||||
safe_unicode(db_repo), safe_unicode(db_repo.repo_state)) | ||||
route = getattr(request.environ.get('routes.route'), 'name', '') | ||||
# allow to delete repos that are somehow damages in filesystem | ||||
if route in ['delete_repo']: | ||||
return | ||||
if db_repo.repo_state in [Repository.STATE_PENDING]: | ||||
if route in ['repo_creating_home']: | ||||
return | ||||
check_url = url('repo_creating_home', repo_name=c.repo_name) | ||||
return redirect(check_url) | ||||
self.rhodecode_db_repo = db_repo | ||||
missing_requirements = False | ||||
try: | ||||
self.rhodecode_repo = self.rhodecode_db_repo.scm_instance() | ||||
except RepositoryRequirementError as e: | ||||
missing_requirements = True | ||||
self._handle_missing_requirements(e) | ||||
if self.rhodecode_repo is None and not missing_requirements: | ||||
log.error('%s this repository is present in database but it ' | ||||
'cannot be created as an scm instance', c.repo_name) | ||||
h.flash(_( | ||||
"The repository at %(repo_name)s cannot be located.") % | ||||
{'repo_name': c.repo_name}, | ||||
category='error', ignore_duplicate=True) | ||||
redirect(url('home')) | ||||
# update last change according to VCS data | ||||
if not missing_requirements: | ||||
commit = db_repo.get_commit( | ||||
pre_load=["author", "date", "message", "parents"]) | ||||
db_repo.update_commit_cache(commit) | ||||
# Prepare context | ||||
c.rhodecode_db_repo = db_repo | ||||
c.rhodecode_repo = self.rhodecode_repo | ||||
c.repository_requirements_missing = missing_requirements | ||||
self._update_global_counters(self.scm_model, db_repo) | ||||
def _update_global_counters(self, scm_model, db_repo): | ||||
""" | ||||
Base variables that are exposed to every page of repository | ||||
""" | ||||
c.repository_pull_requests = scm_model.get_pull_requests(db_repo) | ||||
def _handle_missing_requirements(self, error): | ||||
self.rhodecode_repo = None | ||||
log.error( | ||||
'Requirements are missing for repository %s: %s', | ||||
c.repo_name, error.message) | ||||
summary_url = url('summary_home', repo_name=c.repo_name) | ||||
statistics_url = url('edit_repo_statistics', repo_name=c.repo_name) | ||||
settings_update_url = url('repo', repo_name=c.repo_name) | ||||
path = request.path | ||||
should_redirect = ( | ||||
path not in (summary_url, settings_update_url) | ||||
and '/settings' not in path or path == statistics_url | ||||
) | ||||
if should_redirect: | ||||
redirect(summary_url) | ||||