simplesvn.py
257 lines
| 9.7 KiB
| text/x-python
|
PythonLexer
r5608 | # Copyright (C) 2010-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/ | ||||
r5459 | import re | |||
import os | ||||
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 | |||
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 | |||
r5459 | from rhodecode.lib.str_utils import safe_str | |||
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'] | ||||
r5399 | has_content_length: bool = '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', '') | |||
r5399 | log.debug('Handling: %s method via `%s` has_content_length:%s', req_method, path_info, has_content_length) | |||
r1 | ||||
r3022 | # stream control flag, based on request and content type... | |||
stream = False | ||||
if req_method in ['MKCOL'] or has_content_length: | ||||
r5459 | # 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. | ||||
data_io = 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']: | ||||
r5459 | # NOTE(marcink): HEAD might be deprecated for SVN 1.14+ protocol | |||
r5215 | 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) | |||
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 | ||||
r5459 | def _get_txn_id(self, environ): | |||
url = environ['RAW_URI'] | ||||
# Define the regex pattern | ||||
pattern = r'/txr/([^/]+)/' | ||||
# 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 | 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): | |||
r5459 | """ | |||
details: https://svn.apache.org/repos/asf/subversion/trunk/notes/http-and-webdav/webdav-protocol | ||||
Read Commands : (OPTIONS, PROPFIND, GET, REPORT) | ||||
GET: fetch info about resources | ||||
PROPFIND: Used to retrieve properties of resources. | ||||
REPORT: Used for specialized queries to the repository. E.g History etc... | ||||
OPTIONS: request is sent to an SVN server, the server responds with information about the available HTTP | ||||
methods and other server capabilities. | ||||
Write Commands : (MKACTIVITY, PROPPATCH, PUT, CHECKOUT, MKCOL, MOVE, | ||||
-------------- COPY, DELETE, LOCK, UNLOCK, MERGE) | ||||
With the exception of LOCK/UNLOCK, every write command performs some | ||||
sort of DeltaV commit operation. In DeltaV, a commit always starts | ||||
by creating a transaction (MKACTIVITY), applies a log message | ||||
(PROPPATCH), does some other write methods, and then ends by | ||||
committing the transaction (MERGE). If the MERGE fails, the client | ||||
may try to remove the transaction with a DELETE. | ||||
PROPPATCH: Used to set and/or remove properties on resources. | ||||
MKCOL: Creates a new collection (directory). | ||||
DELETE: Removes a resource. | ||||
COPY and MOVE: Used for copying and moving resources. | ||||
MERGE: Used to merge changes from different branches. | ||||
CHECKOUT, CHECKIN, UNCHECKOUT: DeltaV methods for managing working resources and versions. | ||||
""" | ||||
r1 | ||||
SCM = 'svn' | ||||
READ_ONLY_COMMANDS = ('OPTIONS', 'PROPFIND', 'GET', 'REPORT') | ||||
r5459 | WRITE_COMMANDS = ('MERGE', 'POST', 'PUT', 'COPY', 'MOVE', 'DELETE', 'MKCOL') | |||
DEFAULT_HTTP_SERVER = 'http://svn: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): | |||
r5459 | # only PUT & MERGE command triggers hooks, so we don't want to start | |||
r2677 | # hooks server too many times. POST however starts the svn transaction | |||
# so we also need to run the init of callback daemon of POST | ||||
r5459 | if environ['REQUEST_METHOD'] not in self.READ_ONLY_COMMANDS: | |||
r2677 | 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 | |||