simplesvn.py
242 lines
| 9.1 KiB
| text/x-python
|
PythonLexer
r1 | ||||
r5088 | # Copyright (C) 2010-2023 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/ | ||||
r2677 | import base64 | |||
r753 | import logging | |||
r5065 | import urllib.request | |||
import urllib.parse | ||||
import urllib.error | ||||
r4919 | import urllib.parse | |||
r1 | ||||
import requests | ||||
r2771 | from pyramid.httpexceptions import HTTPNotAcceptable | |||
r1 | ||||
r5328 | from rhodecode import ConfigGet | |||
r2932 | from rhodecode.lib import rc_cache | |||
r1 | from rhodecode.lib.middleware import simplevcs | |||
r5032 | from rhodecode.lib.middleware.utils import get_path_info | |||
r1 | from rhodecode.lib.utils import is_valid_repo | |||
r5156 | from rhodecode.lib.str_utils import safe_str, safe_int, safe_bytes | |||
r5065 | from rhodecode.lib.type_utils import str2bool | |||
r2677 | from rhodecode.lib.ext_json import json | |||
r5329 | from rhodecode.lib.hook_daemon.base import store_txn_id_data | |||
r1 | ||||
r753 | log = logging.getLogger(__name__) | |||
r1 | ||||
class SimpleSvnApp(object): | ||||
IGNORED_HEADERS = [ | ||||
'connection', 'keep-alive', 'content-encoding', | ||||
Martin Bornhold
|
r473 | 'transfer-encoding', 'content-length'] | ||
r2389 | rc_extras = {} | |||
r1 | def __init__(self, config): | |||
self.config = config | ||||
r5215 | self.session = requests.Session() | |||
r1 | ||||
def __call__(self, environ, start_response): | ||||
request_headers = self._get_request_headers(environ) | ||||
r5156 | data_io = environ['wsgi.input'] | |||
req_method: str = environ['REQUEST_METHOD'] | ||||
r3022 | has_content_length = 'CONTENT_LENGTH' in environ | |||
r5032 | ||||
r3323 | path_info = self._get_url( | |||
r5032 | self.config.get('subversion_http_server_url', ''), get_path_info(environ)) | |||
r3022 | transfer_encoding = environ.get('HTTP_TRANSFER_ENCODING', '') | |||
log.debug('Handling: %s method via `%s`', req_method, path_info) | ||||
r1 | ||||
r3022 | # stream control flag, based on request and content type... | |||
stream = False | ||||
if req_method in ['MKCOL'] or has_content_length: | ||||
data_processed = False | ||||
# read chunk to check if we have txn-with-props | ||||
r5156 | initial_data: bytes = data_io.read(1024) | |||
if initial_data.startswith(b'(create-txn-with-props'): | ||||
data_io = initial_data + data_io.read() | ||||
r2677 | # store on-the-fly our rc_extra using svn revision properties | |||
# those can be read later on in hooks executed so we have a way | ||||
# to pass in the data into svn hooks | ||||
rc_data = base64.urlsafe_b64encode(json.dumps(self.rc_extras)) | ||||
r5156 | rc_data_len = str(len(rc_data)) | |||
r3022 | # header defines data length, and serialized data | |||
r5156 | skel = b' rc-scm-extras %b %b' % (safe_bytes(rc_data_len), safe_bytes(rc_data)) | |||
data_io = data_io[:-2] + skel + b'))' | ||||
r3022 | data_processed = True | |||
r1 | ||||
r3022 | if not data_processed: | |||
# NOTE(johbo): Avoid that we end up with sending the request in chunked | ||||
# transfer encoding (mainly on Gunicorn). If we know the content | ||||
# length, then we should transfer the payload in one request. | ||||
r5156 | data_io = initial_data + data_io.read() | |||
r2677 | ||||
r3022 | if req_method in ['GET', 'PUT'] or transfer_encoding == 'chunked': | |||
r5156 | # NOTE(marcink): when getting/uploading files, we want to STREAM content | |||
r3022 | # back to the client/proxy instead of buffering it here... | |||
stream = True | ||||
stream = stream | ||||
r3573 | log.debug('Calling SVN PROXY at `%s`, using method:%s. Stream: %s', | |||
path_info, req_method, stream) | ||||
r5156 | ||||
r5215 | call_kwargs = dict( | |||
data=data_io, | ||||
headers=request_headers, | ||||
stream=stream | ||||
) | ||||
if req_method in ['HEAD', 'DELETE']: | ||||
del call_kwargs['data'] | ||||
r3573 | try: | |||
r5215 | response = self.session.request( | |||
req_method, path_info, **call_kwargs) | ||||
r3573 | except requests.ConnectionError: | |||
log.exception('ConnectionError occurred for endpoint %s', path_info) | ||||
raise | ||||
r1 | ||||
r1586 | if response.status_code not in [200, 401]: | |||
r3827 | text = '\n{}'.format(safe_str(response.text)) if response.text else '' | |||
r1586 | if response.status_code >= 500: | |||
r3573 | log.error('Got SVN response:%s with text:`%s`', response, text) | |||
r1586 | else: | |||
r3573 | log.debug('Got SVN response:%s with text:`%s`', response, text) | |||
r2403 | else: | |||
log.debug('got response code: %s', response.status_code) | ||||
r1586 | ||||
r1 | response_headers = self._get_response_headers(response.headers) | |||
r2677 | ||||
if response.headers.get('SVN-Txn-name'): | ||||
svn_tx_id = response.headers.get('SVN-Txn-name') | ||||
r2970 | txn_id = rc_cache.utils.compute_key_from_params( | |||
r2677 | self.config['repository'], svn_tx_id) | |||
port = safe_int(self.rc_extras['hooks_uri'].split(':')[-1]) | ||||
store_txn_id_data(txn_id, {'port': port}) | ||||
r5156 | start_response(f'{response.status_code} {response.reason}', response_headers) | |||
r1 | return response.iter_content(chunk_size=1024) | |||
r3323 | def _get_url(self, svn_http_server, path): | |||
svn_http_server_url = (svn_http_server or '').rstrip('/') | ||||
r4950 | url_path = urllib.parse.urljoin(svn_http_server_url + '/', (path or '').lstrip('/')) | |||
r4914 | url_path = urllib.parse.quote(url_path, safe="/:=~+!$,;'") | |||
r1586 | return url_path | |||
r1 | ||||
def _get_request_headers(self, environ): | ||||
headers = {} | ||||
r5224 | whitelist = { | |||
'Authorization': {} | ||||
} | ||||
r1 | for key in environ: | |||
r5224 | if key in whitelist: | |||
headers[key] = environ[key] | ||||
elif not key.startswith('HTTP_'): | ||||
r1 | continue | |||
r5224 | else: | |||
new_key = key.split('_') | ||||
new_key = [k.capitalize() for k in new_key[1:]] | ||||
new_key = '-'.join(new_key) | ||||
headers[new_key] = environ[key] | ||||
r1 | ||||
if 'CONTENT_TYPE' in environ: | ||||
headers['Content-Type'] = environ['CONTENT_TYPE'] | ||||
if 'CONTENT_LENGTH' in environ: | ||||
headers['Content-Length'] = environ['CONTENT_LENGTH'] | ||||
return headers | ||||
def _get_response_headers(self, headers): | ||||
Martin Bornhold
|
r608 | headers = [ | ||
r1 | (h, headers[h]) | |||
for h in headers | ||||
if h.lower() not in self.IGNORED_HEADERS | ||||
] | ||||
Martin Bornhold
|
r608 | return headers | ||
r1 | ||||
r754 | class DisabledSimpleSvnApp(object): | |||
def __init__(self, config): | ||||
self.config = config | ||||
def __call__(self, environ, start_response): | ||||
reason = 'Cannot handle SVN call because: SVN HTTP Proxy is not enabled' | ||||
log.warning(reason) | ||||
return HTTPNotAcceptable(reason)(environ, start_response) | ||||
r1 | class SimpleSvn(simplevcs.SimpleVCS): | |||
SCM = 'svn' | ||||
READ_ONLY_COMMANDS = ('OPTIONS', 'PROPFIND', 'GET', 'REPORT') | ||||
r754 | DEFAULT_HTTP_SERVER = 'http://localhost:8090' | |||
r1 | ||||
def _get_repository_name(self, environ): | ||||
""" | ||||
Gets repository name out of PATH_INFO header | ||||
:param environ: environ where PATH_INFO is stored | ||||
""" | ||||
r5032 | path = get_path_info(environ).split('!') | |||
r1 | repo_name = path[0].strip('/') | |||
# SVN includes the whole path in it's requests, including | ||||
# subdirectories inside the repo. Therefore we have to search for | ||||
# the repo root directory. | ||||
r2402 | if not is_valid_repo( | |||
repo_name, self.base_path, explicit_scm=self.SCM): | ||||
r1 | current_path = '' | |||
for component in repo_name.split('/'): | ||||
current_path += component | ||||
r2402 | if is_valid_repo( | |||
current_path, self.base_path, explicit_scm=self.SCM): | ||||
r1 | return current_path | |||
current_path += '/' | ||||
return repo_name | ||||
def _get_action(self, environ): | ||||
return ( | ||||
'pull' | ||||
if environ['REQUEST_METHOD'] in self.READ_ONLY_COMMANDS | ||||
else 'push') | ||||
r2677 | def _should_use_callback_daemon(self, extras, environ, action): | |||
# only MERGE command triggers hooks, so we don't want to start | ||||
# hooks server too many times. POST however starts the svn transaction | ||||
# so we also need to run the init of callback daemon of POST | ||||
if environ['REQUEST_METHOD'] in ['MERGE', 'POST']: | ||||
return True | ||||
return False | ||||
r1 | def _create_wsgi_app(self, repo_path, repo_name, config): | |||
r754 | if self._is_svn_enabled(): | |||
return SimpleSvnApp(config) | ||||
# we don't have http proxy enabled return dummy request handler | ||||
return DisabledSimpleSvnApp(config) | ||||
def _is_svn_enabled(self): | ||||
r5328 | return ConfigGet().get_bool('vcs.svn.proxy.enabled') | |||
r1 | ||||
r3781 | def _create_config(self, extras, repo_name, scheme='http'): | |||
r5328 | server_url = ConfigGet().get_str('vcs.svn.proxy.host') | |||
r754 | server_url = server_url or self.DEFAULT_HTTP_SERVER | |||
extras['subversion_http_server_url'] = server_url | ||||
r1 | return extras | |||