simplevcs.py
659 lines
| 26.4 KiB
| text/x-python
|
PythonLexer
r5608 | # Copyright (C) 2014-2024 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/ | ||||
""" | ||||
SimpleVCS middleware for handling protocol request (push/clone etc.) | ||||
It's implemented with basic auth function | ||||
""" | ||||
import os | ||||
r2154 | import re | |||
r1 | import logging | |||
import importlib | ||||
from functools import wraps | ||||
r2154 | import time | |||
r1 | from paste.httpheaders import REMOTE_USER, AUTH_TYPE | |||
r2677 | ||||
r5607 | from pyramid.httpexceptions import HTTPNotFound, HTTPForbidden, HTTPNotAcceptable, HTTPInternalServerError | |||
r2404 | from zope.cachedescriptors.property import Lazy as LazyProperty | |||
r1 | ||||
import rhodecode | ||||
r2845 | from rhodecode.authentication.base import authenticate, VCS_TYPE, loadplugin | |||
r2932 | from rhodecode.lib import rc_cache | |||
r5459 | from rhodecode.lib.svn_txn_utils import store_txn_id_data | |||
r1 | from rhodecode.lib.auth import AuthUser, HasPermissionAnyMiddleware | |||
r5607 | from rhodecode.lib.base import BasicAuth, get_ip_addr, get_user_agent, vcs_operation_context | |||
from rhodecode.lib.exceptions import UserCreationError, NotAllowedToCreateUserError | ||||
from rhodecode.lib.hook_daemon.utils import prepare_callback_daemon | ||||
r1 | from rhodecode.lib.middleware import appenlight | |||
r1409 | from rhodecode.lib.middleware.utils import scm_app_http | |||
r5459 | from rhodecode.lib.str_utils import safe_bytes, safe_int | |||
r2154 | from rhodecode.lib.utils import is_valid_repo, SLUG_RE | |||
r5065 | from rhodecode.lib.utils2 import safe_str, fix_PATH, str2bool | |||
Martin Bornhold
|
r590 | from rhodecode.lib.vcs.conf import settings as vcs_settings | ||
r754 | from rhodecode.lib.vcs.backends import base | |||
r2677 | ||||
r1 | from rhodecode.model import meta | |||
Martin Bornhold
|
r894 | from rhodecode.model.db import User, Repository, PullRequest | ||
r1 | from rhodecode.model.scm import ScmModel | |||
Martin Bornhold
|
r894 | from rhodecode.model.pull_request import PullRequestModel | ||
r2351 | from rhodecode.model.settings import SettingsModel, VcsSettingsModel | |||
r754 | ||||
r1 | log = logging.getLogger(__name__) | |||
def initialize_generator(factory): | ||||
""" | ||||
Initializes the returned generator by draining its first element. | ||||
This can be used to give a generator an initializer, which is the code | ||||
up to the first yield statement. This decorator enforces that the first | ||||
produced element has the value ``"__init__"`` to make its special | ||||
purpose very explicit in the using code. | ||||
""" | ||||
@wraps(factory) | ||||
def wrapper(*args, **kwargs): | ||||
gen = factory(*args, **kwargs) | ||||
try: | ||||
r4936 | init = next(gen) | |||
r1 | except StopIteration: | |||
r5607 | raise ValueError("Generator must yield at least one element.") | |||
r1 | if init != "__init__": | |||
raise ValueError('First yielded element must be "__init__".') | ||||
return gen | ||||
r5607 | ||||
r1 | return wrapper | |||
class SimpleVCS(object): | ||||
"""Common functionality for SCM HTTP handlers.""" | ||||
r5607 | SCM = "unknown" | |||
r1 | ||||
r887 | acl_repo_name = None | |||
url_repo_name = None | ||||
vcs_repo_name = None | ||||
r2389 | rc_extras = {} | |||
r887 | ||||
Martin Bornhold
|
r902 | # We have to handle requests to shadow repositories different than requests | ||
# to normal repositories. Therefore we have to distinguish them. To do this | ||||
# we use this regex which will match only on URLs pointing to shadow | ||||
# repositories. | ||||
shadow_repo_re = re.compile( | ||||
r5607 | "(?P<groups>(?:{slug_pat}/)*)" # repo groups | |||
"(?P<target>{slug_pat})/" # target repo | ||||
"pull-request/(?P<pr_id>\\d+)/" # pull request | ||||
"repository$".format(slug_pat=SLUG_RE.pattern) # shadow repo | ||||
) | ||||
Martin Bornhold
|
r902 | |||
r2351 | def __init__(self, config, registry): | |||
Martin Bornhold
|
r591 | self.registry = registry | ||
r1 | self.config = config | |||
r757 | # re-populated by specialized middleware | |||
r754 | self.repo_vcs_config = base.Config() | |||
r2351 | ||||
r4220 | rc_settings = SettingsModel().get_all_settings(cache=True, from_request=False) | |||
r5607 | realm = rc_settings.get("rhodecode_realm") or "RhodeCode AUTH" | |||
r4220 | ||||
r1 | # authenticate this VCS request using authfunc | |||
r5607 | auth_ret_code_detection = str2bool(self.config.get("auth_ret_code_detection", False)) | |||
Martin Bornhold
|
r591 | self.authenticate = BasicAuth( | ||
r5607 | "", authenticate, registry, config.get("auth_ret_code"), auth_ret_code_detection, rc_realm=realm | |||
) | ||||
self.ip_addr = "0.0.0.0" | ||||
r1 | ||||
r2404 | @LazyProperty | |||
def global_vcs_config(self): | ||||
try: | ||||
return VcsSettingsModel().get_ui_settings_as_config_obj() | ||||
except Exception: | ||||
return base.Config() | ||||
r2351 | @property | |||
def base_path(self): | ||||
r5607 | settings_path = self.config.get("repo_store.path") | |||
r2404 | ||||
r2362 | if not settings_path: | |||
r5607 | raise ValueError("FATAL: repo_store.path is empty") | |||
r2362 | return settings_path | |||
r2351 | ||||
Martin Bornhold
|
r889 | def set_repo_names(self, environ): | ||
""" | ||||
Martin Bornhold
|
r892 | This will populate the attributes acl_repo_name, url_repo_name, | ||
Martin Bornhold
|
r904 | vcs_repo_name and is_shadow_repo. In case of requests to normal (non | ||
shadow) repositories all names are equal. In case of requests to a | ||||
shadow repository the acl-name points to the target repo of the pull | ||||
request and the vcs-name points to the shadow repo file system path. | ||||
The url-name is always the URL used by the vcs client program. | ||||
Martin Bornhold
|
r889 | |||
Martin Bornhold
|
r904 | Example in case of a shadow repo: | ||
acl_repo_name = RepoGroup/MyRepo | ||||
url_repo_name = RepoGroup/MyRepo/pull-request/3/repository | ||||
vcs_repo_name = /repo/base/path/RepoGroup/.__shadow_MyRepo_pr-3' | ||||
""" | ||||
# First we set the repo name from URL for all attributes. This is the | ||||
# default if handling normal (non shadow) repo requests. | ||||
self.url_repo_name = self._get_repository_name(environ) | ||||
self.acl_repo_name = self.vcs_repo_name = self.url_repo_name | ||||
self.is_shadow_repo = False | ||||
# Check if this is a request to a shadow repository. | ||||
Martin Bornhold
|
r902 | match = self.shadow_repo_re.match(self.url_repo_name) | ||
Martin Bornhold
|
r889 | if match: | ||
match_dict = match.groupdict() | ||||
Martin Bornhold
|
r892 | |||
Martin Bornhold
|
r904 | # Build acl repo name from regex match. | ||
r5607 | acl_repo_name = safe_str( | |||
"{groups}{target}".format(groups=match_dict["groups"] or "", target=match_dict["target"]) | ||||
) | ||||
Martin Bornhold
|
r904 | |||
# Retrieve pull request instance by ID from regex match. | ||||
r5607 | pull_request = PullRequest.get(match_dict["pr_id"]) | |||
Martin Bornhold
|
r892 | |||
Martin Bornhold
|
r904 | # Only proceed if we got a pull request and if acl repo name from | ||
# URL equals the target repo name of the pull request. | ||||
r3931 | if pull_request and (acl_repo_name == pull_request.target_repo.repo_name): | |||
Martin Bornhold
|
r904 | # Get file system path to shadow repository. | ||
workspace_id = PullRequestModel()._workspace_id(pull_request) | ||||
r3931 | vcs_repo_name = pull_request.target_repo.get_shadow_repository_path(workspace_id) | |||
Martin Bornhold
|
r904 | |||
# Store names for later usage. | ||||
self.vcs_repo_name = vcs_repo_name | ||||
self.acl_repo_name = acl_repo_name | ||||
self.is_shadow_repo = True | ||||
r5607 | log.debug( | |||
"Setting all VCS repository names: %s", | ||||
{ | ||||
"acl_repo_name": self.acl_repo_name, | ||||
"url_repo_name": self.url_repo_name, | ||||
"vcs_repo_name": self.vcs_repo_name, | ||||
}, | ||||
) | ||||
Martin Bornhold
|
r889 | |||
r1 | @property | |||
def scm_app(self): | ||||
r5607 | custom_implementation = self.config["vcs.scm_app_implementation"] | |||
if custom_implementation == "http": | ||||
log.debug("Using HTTP implementation of scm app.") | ||||
Martin Bornhold
|
r962 | scm_app_impl = scm_app_http | ||
else: | ||||
r5607 | log.debug('Using custom implementation of scm_app: "{}"'.format(custom_implementation)) | |||
r1 | scm_app_impl = importlib.import_module(custom_implementation) | |||
return scm_app_impl | ||||
def _get_by_id(self, repo_name): | ||||
""" | ||||
Gets a special pattern _<ID> from clone url and tries to replace it | ||||
r757 | with a repository_name for support of _<ID> non changeable urls | |||
r1 | """ | |||
r5607 | data = repo_name.split("/") | |||
r1 | if len(data) >= 2: | |||
from rhodecode.model.repo import RepoModel | ||||
r5607 | ||||
r1 | by_id_match = RepoModel().get_repo_by_id(repo_name) | |||
if by_id_match: | ||||
data[1] = by_id_match.repo_name | ||||
r5031 | # Because PEP-3333-WSGI uses bytes-tunneled-in-latin-1 as PATH_INFO | |||
# and we use this data | ||||
r5607 | maybe_new_path = "/".join(data) | |||
return safe_bytes(maybe_new_path).decode("latin1") | ||||
r1 | ||||
def _invalidate_cache(self, repo_name): | ||||
""" | ||||
Set's cache for this repository for invalidation on next access | ||||
:param repo_name: full repo name, also a cache key | ||||
""" | ||||
ScmModel().mark_for_invalidation(repo_name) | ||||
def is_valid_and_existing_repo(self, repo_name, base_path, scm_type): | ||||
db_repo = Repository.get_by_repo_name(repo_name) | ||||
if not db_repo: | ||||
r5607 | log.debug("Repository `%s` not found inside the database.", repo_name) | |||
r1 | return False | |||
if db_repo.repo_type != scm_type: | ||||
log.warning( | ||||
r5607 | "Repository `%s` have incorrect scm_type, expected %s got %s", repo_name, db_repo.repo_type, scm_type | |||
) | ||||
r1 | return False | |||
r2519 | config = db_repo._config | |||
r5607 | config.set("extensions", "largefiles", "") | |||
return is_valid_repo(repo_name, base_path, explicit_scm=scm_type, expect_scm=scm_type, config=config) | ||||
r1 | ||||
def valid_and_active_user(self, user): | ||||
""" | ||||
Checks if that user is not empty, and if it's actually object it checks | ||||
if he's active. | ||||
:param user: user object or None | ||||
:return: boolean | ||||
""" | ||||
if user is None: | ||||
return False | ||||
elif user.active: | ||||
return True | ||||
return False | ||||
r2069 | @property | |||
def is_shadow_repo_dir(self): | ||||
return os.path.isdir(self.vcs_repo_name) | ||||
r5607 | def _check_permission( | |||
self, action, user, auth_user, repo_name, ip_addr=None, plugin_id="", plugin_cache_active=False, cache_ttl=0 | ||||
): | ||||
r1 | """ | |||
Checks permissions using action (push/pull) user and repository | ||||
r2154 | name. If plugin_cache and ttl is set it will use the plugin which | |||
authenticated the user to store the cached permissions result for N | ||||
amount of seconds as in cache_ttl | ||||
r1 | ||||
:param action: push or pull action | ||||
:param user: user instance | ||||
:param repo_name: repository name | ||||
""" | ||||
r2154 | ||||
r5607 | log.debug("AUTH_CACHE_TTL for permissions `%s` active: %s (TTL: %s)", plugin_id, plugin_cache_active, cache_ttl) | |||
r2154 | ||||
r2845 | user_id = user.user_id | |||
r5607 | cache_namespace_uid = f"cache_user_auth.{rc_cache.PERMISSIONS_CACHE_VER}.{user_id}" | |||
region = rc_cache.get_or_create_region("cache_perms", cache_namespace_uid) | ||||
r1 | ||||
r5607 | @region.conditional_cache_on_arguments( | |||
namespace=cache_namespace_uid, expiration_time=cache_ttl, condition=plugin_cache_active | ||||
) | ||||
def compute_perm_vcs(cache_name, plugin_id, action, user_id, repo_name, ip_addr): | ||||
log.debug("auth: calculating permission access now for vcs operation: %s", action) | ||||
r2154 | # check IP | |||
inherit = user.inherit_default_permissions | ||||
r5607 | ip_allowed = AuthUser.check_ip_allowed(user_id, ip_addr, inherit_from_default=inherit) | |||
r2154 | if ip_allowed: | |||
r5607 | log.info("Access for IP:%s allowed", ip_addr) | |||
r2154 | else: | |||
r1 | return False | |||
r5607 | if action == "push": | |||
perms = ("repository.write", "repository.admin") | ||||
r2979 | if not HasPermissionAnyMiddleware(*perms)(auth_user, repo_name): | |||
r2154 | return False | |||
else: | ||||
# any other action need at least read permission | ||||
r5607 | perms = ("repository.read", "repository.write", "repository.admin") | |||
r2979 | if not HasPermissionAnyMiddleware(*perms)(auth_user, repo_name): | |||
r2154 | return False | |||
return True | ||||
r2845 | start = time.time() | |||
r5607 | log.debug("Running plugin `%s` permissions check", plugin_id) | |||
r2845 | ||||
# for environ based auth, password can be empty, but then the validation is | ||||
# on the server that fills in the env data needed for authentication | ||||
r5607 | perm_result = compute_perm_vcs("vcs_permissions", plugin_id, action, user.user_id, repo_name, ip_addr) | |||
r1 | ||||
r2154 | auth_time = time.time() - start | |||
r5607 | log.debug( | |||
"Permissions for plugin `%s` completed in %.4fs, " "expiration time of fetched cache %.1fs.", | ||||
plugin_id, | ||||
auth_time, | ||||
cache_ttl, | ||||
) | ||||
r2154 | ||||
return perm_result | ||||
r1 | ||||
r3781 | def _get_http_scheme(self, environ): | |||
try: | ||||
r5607 | return environ["wsgi.url_scheme"] | |||
r3781 | except Exception: | |||
r5607 | log.exception("Failed to read http scheme") | |||
return "http" | ||||
r3781 | ||||
r2425 | def _get_default_cache_ttl(self): | |||
# take AUTH_CACHE_TTL from the `rhodecode` auth plugin | ||||
r5607 | plugin = loadplugin("egg:rhodecode-enterprise-ce#rhodecode") | |||
r2425 | plugin_settings = plugin.get_settings() | |||
r5607 | plugin_cache_active, cache_ttl = plugin.get_ttl_cache(plugin_settings) or (False, 0) | |||
r2425 | return plugin_cache_active, cache_ttl | |||
r1 | def __call__(self, environ, start_response): | |||
try: | ||||
return self._handle_request(environ, start_response) | ||||
except Exception: | ||||
log.exception("Exception while handling request") | ||||
appenlight.track_exception(environ) | ||||
return HTTPInternalServerError()(environ, start_response) | ||||
finally: | ||||
meta.Session.remove() | ||||
def _handle_request(self, environ, start_response): | ||||
Martin Bornhold
|
r898 | if not self.url_repo_name: | ||
r5607 | log.warning("Repository name is empty: %s", self.url_repo_name) | |||
r757 | # failed to get repo name, we fail now | |||
return HTTPNotFound()(environ, start_response) | ||||
r5607 | log.debug("Extracted repo name is %s", self.url_repo_name) | |||
r757 | ||||
r1 | ip_addr = get_ip_addr(environ) | |||
r1711 | user_agent = get_user_agent(environ) | |||
r1 | username = None | |||
# skip passing error to error controller | ||||
r5607 | environ["pylons.status_code_redirect"] = True | |||
r1 | ||||
# ====================================================================== | ||||
# GET ACTION PULL or PUSH | ||||
# ====================================================================== | ||||
action = self._get_action(environ) | ||||
# ====================================================================== | ||||
Martin Bornhold
|
r891 | # Check if this is a request to a shadow repository of a pull request. | ||
# In this case only pull action is allowed. | ||||
# ====================================================================== | ||||
r5607 | if self.is_shadow_repo and action != "pull": | |||
reason = "Only pull action is allowed for shadow repositories." | ||||
log.debug("User not allowed to proceed, %s", reason) | ||||
Martin Bornhold
|
r891 | return HTTPNotAcceptable(reason)(environ, start_response) | ||
r2069 | # Check if the shadow repo actually exists, in case someone refers | |||
# to it, and it has been deleted because of successful merge. | ||||
if self.is_shadow_repo and not self.is_shadow_repo_dir: | ||||
r5607 | log.debug("Shadow repo detected, and shadow repo dir `%s` is missing", self.is_shadow_repo_dir) | |||
r2069 | return HTTPNotFound()(environ, start_response) | |||
Martin Bornhold
|
r891 | # ====================================================================== | ||
r1 | # CHECK ANONYMOUS PERMISSION | |||
# ====================================================================== | ||||
r2979 | detect_force_push = False | |||
check_branch_perms = False | ||||
r5607 | if action in ["pull", "push"]: | |||
r2979 | user_obj = anonymous_user = User.get_default_user() | |||
auth_user = user_obj.AuthUser() | ||||
r1 | username = anonymous_user.username | |||
if anonymous_user.active: | ||||
r2425 | plugin_cache_active, cache_ttl = self._get_default_cache_ttl() | |||
r1 | # ONLY check permissions if the user is activated | |||
anonymous_perm = self._check_permission( | ||||
r5607 | action, | |||
anonymous_user, | ||||
auth_user, | ||||
self.acl_repo_name, | ||||
ip_addr, | ||||
plugin_id="anonymous_access", | ||||
r2593 | plugin_cache_active=plugin_cache_active, | |||
cache_ttl=cache_ttl, | ||||
r2425 | ) | |||
r1 | else: | |||
anonymous_perm = False | ||||
if not anonymous_user.active or not anonymous_perm: | ||||
if not anonymous_user.active: | ||||
r5607 | log.debug("Anonymous access is disabled, running " "authentication") | |||
r1 | ||||
if not anonymous_perm: | ||||
r5607 | log.debug( | |||
"Not enough credentials to access repo: `%s` " "repository as anonymous user", | ||||
self.acl_repo_name, | ||||
) | ||||
r5065 | ||||
r1 | username = None | |||
# ============================================================== | ||||
# DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE | ||||
# NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS | ||||
# ============================================================== | ||||
# try to auth based on environ, container auth methods | ||||
r5607 | log.debug("Running PRE-AUTH for container|headers based authentication") | |||
r5065 | ||||
# headers auth, by just reading special headers and bypass the auth with user/passwd | ||||
Martin Bornhold
|
r591 | pre_auth = authenticate( | ||
r5607 | "", "", environ, VCS_TYPE, registry=self.registry, acl_repo_name=self.acl_repo_name | |||
) | ||||
r5065 | ||||
r5607 | if pre_auth and pre_auth.get("username"): | |||
username = pre_auth["username"] | ||||
log.debug("PRE-AUTH got `%s` as username", username) | ||||
r2154 | if pre_auth: | |||
r5607 | log.debug("PRE-AUTH successful from %s", pre_auth.get("auth_data", {}).get("_plugin")) | |||
r1 | ||||
# If not authenticated by the container, running basic auth | ||||
r1510 | # before inject the calling repo_name for special scope checks | |||
self.authenticate.acl_repo_name = self.acl_repo_name | ||||
r2154 | ||||
plugin_cache_active, cache_ttl = False, 0 | ||||
plugin = None | ||||
r5065 | ||||
# regular auth chain | ||||
r1 | if not username: | |||
r2140 | self.authenticate.realm = self.authenticate.get_rc_realm() | |||
r1 | ||||
try: | ||||
r2154 | auth_result = self.authenticate(environ) | |||
r1 | except (UserCreationError, NotAllowedToCreateUserError) as e: | |||
log.error(e) | ||||
reason = safe_str(e) | ||||
return HTTPNotAcceptable(reason)(environ, start_response) | ||||
r2154 | if isinstance(auth_result, dict): | |||
r5607 | AUTH_TYPE.update(environ, "basic") | |||
REMOTE_USER.update(environ, auth_result["username"]) | ||||
username = auth_result["username"] | ||||
plugin = auth_result.get("auth_data", {}).get("_plugin") | ||||
log.info("MAIN-AUTH successful for user `%s` from %s plugin", username, plugin) | ||||
r2154 | ||||
r5607 | plugin_cache_active, cache_ttl = auth_result.get("auth_data", {}).get("_ttl_cache") or ( | |||
False, | ||||
0, | ||||
) | ||||
r1 | else: | |||
r3328 | return auth_result.wsgi_application(environ, start_response) | |||
r2154 | ||||
r1 | # ============================================================== | |||
# CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME | ||||
# ============================================================== | ||||
user = User.get_by_username(username) | ||||
if not self.valid_and_active_user(user): | ||||
return HTTPForbidden()(environ, start_response) | ||||
username = user.username | ||||
r2930 | user_id = user.user_id | |||
r1 | ||||
# check user attributes for password change flag | ||||
user_obj = user | ||||
r2979 | auth_user = user_obj.AuthUser() | |||
r5607 | if ( | |||
user_obj | ||||
and user_obj.username != User.DEFAULT_USER | ||||
and user_obj.user_data.get("force_password_change") | ||||
): | ||||
reason = "password change required" | ||||
log.debug("User not allowed to authenticate, %s", reason) | ||||
r1 | return HTTPNotAcceptable(reason)(environ, start_response) | |||
# check permissions for this repository | ||||
r757 | perm = self._check_permission( | |||
r5607 | action, user, auth_user, self.acl_repo_name, ip_addr, plugin, plugin_cache_active, cache_ttl | |||
) | ||||
r1 | if not perm: | |||
return HTTPForbidden()(environ, start_response) | ||||
r5607 | environ["rc_auth_user_id"] = str(user_id) | |||
r1 | ||||
r5607 | if action == "push": | |||
r2979 | perms = auth_user.get_branch_permissions(self.acl_repo_name) | |||
if perms: | ||||
check_branch_perms = True | ||||
detect_force_push = True | ||||
r1 | # extras are injected into UI object and later available | |||
r2154 | # in hooks executed by RhodeCode | |||
r5607 | check_locking = _should_check_locking(environ.get("QUERY_STRING")) | |||
r2979 | ||||
r1 | extras = vcs_operation_context( | |||
r5607 | environ, | |||
repo_name=self.acl_repo_name, | ||||
username=username, | ||||
action=action, | ||||
scm=self.SCM, | ||||
check_locking=check_locking, | ||||
is_shadow_repo=self.is_shadow_repo, | ||||
check_branch_perms=check_branch_perms, | ||||
detect_force_push=detect_force_push, | ||||
Martin Bornhold
|
r899 | ) | ||
r1 | ||||
# ====================================================================== | ||||
# REQUEST HANDLING | ||||
# ====================================================================== | ||||
r5607 | repo_path = os.path.join(safe_str(self.base_path), safe_str(self.vcs_repo_name)) | |||
log.debug("Repository path is %s", repo_path) | ||||
r1 | ||||
fix_PATH() | ||||
log.info( | ||||
r1711 | '%s action on %s repo "%s" by "%s" from %s %s', | |||
r5607 | action, | |||
self.SCM, | ||||
safe_str(self.url_repo_name), | ||||
safe_str(username), | ||||
ip_addr, | ||||
user_agent, | ||||
) | ||||
Martin Bornhold
|
r600 | |||
r5607 | return self._generate_vcs_response(environ, start_response, repo_path, extras, action) | |||
r1 | ||||
r5459 | def _get_txn_id(self, environ): | |||
r5607 | for k in ["RAW_URI", "HTTP_DESTINATION"]: | |||
r5459 | url = environ.get(k) | |||
if not url: | ||||
continue | ||||
# regex to search for svn-txn-id | ||||
r5607 | pattern = r"/!svn/txr/([^/]+)/" | |||
r5459 | ||||
# Search for the pattern in the URL | ||||
match = re.search(pattern, url) | ||||
# Check if a match is found and extract the captured group | ||||
if match: | ||||
txn_id = match.group(1) | ||||
return txn_id | ||||
r1 | @initialize_generator | |||
r5607 | def _generate_vcs_response(self, environ, start_response, repo_path, extras, action): | |||
r1 | """ | |||
Returns a generator for the response content. | ||||
This method is implemented as a generator, so that it can trigger | ||||
the cache validation after all content sent back to the client. It | ||||
also handles the locking exceptions which will be triggered when | ||||
the first chunk is produced by the underlying WSGI application. | ||||
""" | ||||
r5607 | svn_txn_id = "" | |||
if action == "push": | ||||
r5459 | svn_txn_id = self._get_txn_id(environ) | |||
r1 | ||||
r5607 | callback_daemon, extras = self._prepare_callback_daemon(extras, environ, action, txn_id=svn_txn_id) | |||
r5459 | ||||
if svn_txn_id: | ||||
txn_id_data = extras.copy() | ||||
r5607 | txn_id_data.update({"req_method": environ["REQUEST_METHOD"]}) | |||
r5459 | ||||
full_repo_path = repo_path | ||||
store_txn_id_data(full_repo_path, svn_txn_id, txn_id_data) | ||||
r5607 | log.debug("HOOKS extras is %s", extras) | |||
r2677 | ||||
r3781 | http_scheme = self._get_http_scheme(environ) | |||
config = self._create_config(extras, self.acl_repo_name, scheme=http_scheme) | ||||
r2677 | app = self._create_wsgi_app(repo_path, self.url_repo_name, config) | |||
with callback_daemon: | ||||
app.rc_extras = extras | ||||
r1 | ||||
r2677 | try: | |||
response = app(environ, start_response) | ||||
finally: | ||||
# This statement works together with the decorator | ||||
# "initialize_generator" above. The decorator ensures that | ||||
# we hit the first yield statement before the generator is | ||||
# returned back to the WSGI server. This is needed to | ||||
# ensure that the call to "app" above triggers the | ||||
# needed callback to "start_response" before the | ||||
# generator is actually used. | ||||
yield "__init__" | ||||
r1 | ||||
r2677 | # iter content | |||
for chunk in response: | ||||
r1 | yield chunk | |||
r2677 | ||||
r669 | try: | |||
r2677 | # invalidate cache on push | |||
r5607 | if action == "push": | |||
Martin Bornhold
|
r905 | self._invalidate_cache(self.url_repo_name) | ||
r669 | finally: | |||
meta.Session.remove() | ||||
r1 | ||||
def _get_repository_name(self, environ): | ||||
"""Get repository name out of the environmnent | ||||
:param environ: WSGI environment | ||||
""" | ||||
raise NotImplementedError() | ||||
def _get_action(self, environ): | ||||
"""Map request commands into a pull or push command. | ||||
:param environ: WSGI environment | ||||
""" | ||||
raise NotImplementedError() | ||||
def _create_wsgi_app(self, repo_path, repo_name, config): | ||||
"""Return the WSGI app that will finally handle the request.""" | ||||
raise NotImplementedError() | ||||
r5607 | def _create_config(self, extras, repo_name, scheme="http"): | |||
r1409 | """Create a safe config representation.""" | |||
r1 | raise NotImplementedError() | |||
r2677 | def _should_use_callback_daemon(self, extras, environ, action): | |||
r5607 | if extras.get("is_shadow_repo"): | |||
r3932 | # we don't want to execute hooks, and callback daemon for shadow repos | |||
return False | ||||
r2677 | return True | |||
def _prepare_callback_daemon(self, extras, environ, action, txn_id=None): | ||||
r5298 | protocol = vcs_settings.HOOKS_PROTOCOL | |||
r5459 | ||||
r2677 | if not self._should_use_callback_daemon(extras, environ, action): | |||
# disable callback daemon for actions that don't require it | ||||
r5607 | protocol = "local" | |||
r2677 | ||||
r5607 | return prepare_callback_daemon(extras, protocol=protocol, txn_id=txn_id) | |||
r1 | ||||
def _should_check_locking(query_string): | ||||
# this is kind of hacky, but due to how mercurial handles client-server | ||||
# server see all operation on commit; bookmarks, phases and | ||||
# obsolescence marker in different transaction, we don't want to check | ||||
# locking on those | ||||
r5607 | return query_string not in ["cmd=listkeys"] | |||