##// END OF EJS Templates
svn: support proxy-prefix properly fixes #5521
marcink -
r3323:1fb993c1 stable
parent child Browse files
Show More
@@ -1,225 +1,226 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 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 import base64
22 22 import logging
23 23 import urllib
24 24 import urlparse
25 25
26 26 import requests
27 27 from pyramid.httpexceptions import HTTPNotAcceptable
28 28
29 29 from rhodecode.lib import rc_cache
30 30 from rhodecode.lib.middleware import simplevcs
31 31 from rhodecode.lib.utils import is_valid_repo
32 32 from rhodecode.lib.utils2 import str2bool, safe_int
33 33 from rhodecode.lib.ext_json import json
34 34 from rhodecode.lib.hooks_daemon import store_txn_id_data
35 35
36 36
37 37 log = logging.getLogger(__name__)
38 38
39 39
40 40 class SimpleSvnApp(object):
41 41 IGNORED_HEADERS = [
42 42 'connection', 'keep-alive', 'content-encoding',
43 43 'transfer-encoding', 'content-length']
44 44 rc_extras = {}
45 45
46 46 def __init__(self, config):
47 47 self.config = config
48 48
49 49 def __call__(self, environ, start_response):
50 50 request_headers = self._get_request_headers(environ)
51 51 data = environ['wsgi.input']
52 52 req_method = environ['REQUEST_METHOD']
53 53 has_content_length = 'CONTENT_LENGTH' in environ
54 path_info = self._get_url(environ['PATH_INFO'])
54 path_info = self._get_url(
55 self.config.get('subversion_http_server_url', ''), environ['PATH_INFO'])
55 56 transfer_encoding = environ.get('HTTP_TRANSFER_ENCODING', '')
56 57 log.debug('Handling: %s method via `%s`', req_method, path_info)
57 58
58 59 # stream control flag, based on request and content type...
59 60 stream = False
60 61
61 62 if req_method in ['MKCOL'] or has_content_length:
62 63 data_processed = False
63 64 # read chunk to check if we have txn-with-props
64 65 initial_data = data.read(1024)
65 66 if initial_data.startswith('(create-txn-with-props'):
66 67 data = initial_data + data.read()
67 68 # store on-the-fly our rc_extra using svn revision properties
68 69 # those can be read later on in hooks executed so we have a way
69 70 # to pass in the data into svn hooks
70 71 rc_data = base64.urlsafe_b64encode(json.dumps(self.rc_extras))
71 72 rc_data_len = len(rc_data)
72 73 # header defines data length, and serialized data
73 74 skel = ' rc-scm-extras {} {}'.format(rc_data_len, rc_data)
74 75 data = data[:-2] + skel + '))'
75 76 data_processed = True
76 77
77 78 if not data_processed:
78 79 # NOTE(johbo): Avoid that we end up with sending the request in chunked
79 80 # transfer encoding (mainly on Gunicorn). If we know the content
80 81 # length, then we should transfer the payload in one request.
81 82 data = initial_data + data.read()
82 83
83 84 if req_method in ['GET', 'PUT'] or transfer_encoding == 'chunked':
84 85 # NOTE(marcink): when getting/uploading files we want to STREAM content
85 86 # back to the client/proxy instead of buffering it here...
86 87 stream = True
87 88
88 89 stream = stream
89 90 log.debug(
90 91 'Calling SVN PROXY: method:%s via `%s`, Stream: %s',
91 92 req_method, path_info, stream)
92 93 response = requests.request(
93 94 req_method, path_info,
94 95 data=data, headers=request_headers, stream=stream)
95 96
96 97 if response.status_code not in [200, 401]:
97 98 if response.status_code >= 500:
98 99 log.error('Got SVN response:%s with text:\n`%s`',
99 100 response, response.text)
100 101 else:
101 102 log.debug('Got SVN response:%s with text:\n`%s`',
102 103 response, response.text)
103 104 else:
104 105 log.debug('got response code: %s', response.status_code)
105 106
106 107 response_headers = self._get_response_headers(response.headers)
107 108
108 109 if response.headers.get('SVN-Txn-name'):
109 110 svn_tx_id = response.headers.get('SVN-Txn-name')
110 111 txn_id = rc_cache.utils.compute_key_from_params(
111 112 self.config['repository'], svn_tx_id)
112 113 port = safe_int(self.rc_extras['hooks_uri'].split(':')[-1])
113 114 store_txn_id_data(txn_id, {'port': port})
114 115
115 116 start_response(
116 117 '{} {}'.format(response.status_code, response.reason),
117 118 response_headers)
118 119 return response.iter_content(chunk_size=1024)
119 120
120 def _get_url(self, path):
121 url_path = urlparse.urljoin(
122 self.config.get('subversion_http_server_url', ''), path)
121 def _get_url(self, svn_http_server, path):
122 svn_http_server_url = (svn_http_server or '').rstrip('/')
123 url_path = urlparse.urljoin(svn_http_server_url + '/', (path or '').lstrip('/'))
123 124 url_path = urllib.quote(url_path, safe="/:=~+!$,;'")
124 125 return url_path
125 126
126 127 def _get_request_headers(self, environ):
127 128 headers = {}
128 129
129 130 for key in environ:
130 131 if not key.startswith('HTTP_'):
131 132 continue
132 133 new_key = key.split('_')
133 134 new_key = [k.capitalize() for k in new_key[1:]]
134 135 new_key = '-'.join(new_key)
135 136 headers[new_key] = environ[key]
136 137
137 138 if 'CONTENT_TYPE' in environ:
138 139 headers['Content-Type'] = environ['CONTENT_TYPE']
139 140
140 141 if 'CONTENT_LENGTH' in environ:
141 142 headers['Content-Length'] = environ['CONTENT_LENGTH']
142 143
143 144 return headers
144 145
145 146 def _get_response_headers(self, headers):
146 147 headers = [
147 148 (h, headers[h])
148 149 for h in headers
149 150 if h.lower() not in self.IGNORED_HEADERS
150 151 ]
151 152
152 153 return headers
153 154
154 155
155 156 class DisabledSimpleSvnApp(object):
156 157 def __init__(self, config):
157 158 self.config = config
158 159
159 160 def __call__(self, environ, start_response):
160 161 reason = 'Cannot handle SVN call because: SVN HTTP Proxy is not enabled'
161 162 log.warning(reason)
162 163 return HTTPNotAcceptable(reason)(environ, start_response)
163 164
164 165
165 166 class SimpleSvn(simplevcs.SimpleVCS):
166 167
167 168 SCM = 'svn'
168 169 READ_ONLY_COMMANDS = ('OPTIONS', 'PROPFIND', 'GET', 'REPORT')
169 170 DEFAULT_HTTP_SERVER = 'http://localhost:8090'
170 171
171 172 def _get_repository_name(self, environ):
172 173 """
173 174 Gets repository name out of PATH_INFO header
174 175
175 176 :param environ: environ where PATH_INFO is stored
176 177 """
177 178 path = environ['PATH_INFO'].split('!')
178 179 repo_name = path[0].strip('/')
179 180
180 181 # SVN includes the whole path in it's requests, including
181 182 # subdirectories inside the repo. Therefore we have to search for
182 183 # the repo root directory.
183 184 if not is_valid_repo(
184 185 repo_name, self.base_path, explicit_scm=self.SCM):
185 186 current_path = ''
186 187 for component in repo_name.split('/'):
187 188 current_path += component
188 189 if is_valid_repo(
189 190 current_path, self.base_path, explicit_scm=self.SCM):
190 191 return current_path
191 192 current_path += '/'
192 193
193 194 return repo_name
194 195
195 196 def _get_action(self, environ):
196 197 return (
197 198 'pull'
198 199 if environ['REQUEST_METHOD'] in self.READ_ONLY_COMMANDS
199 200 else 'push')
200 201
201 202 def _should_use_callback_daemon(self, extras, environ, action):
202 203 # only MERGE command triggers hooks, so we don't want to start
203 204 # hooks server too many times. POST however starts the svn transaction
204 205 # so we also need to run the init of callback daemon of POST
205 206 if environ['REQUEST_METHOD'] in ['MERGE', 'POST']:
206 207 return True
207 208 return False
208 209
209 210 def _create_wsgi_app(self, repo_path, repo_name, config):
210 211 if self._is_svn_enabled():
211 212 return SimpleSvnApp(config)
212 213 # we don't have http proxy enabled return dummy request handler
213 214 return DisabledSimpleSvnApp(config)
214 215
215 216 def _is_svn_enabled(self):
216 217 conf = self.repo_vcs_config
217 218 return str2bool(conf.get('vcs_svn_proxy', 'http_requests_enabled'))
218 219
219 220 def _create_config(self, extras, repo_name):
220 221 conf = self.repo_vcs_config
221 222 server_url = conf.get('vcs_svn_proxy', 'http_server_url')
222 223 server_url = server_url or self.DEFAULT_HTTP_SERVER
223 224
224 225 extras['subversion_http_server_url'] = server_url
225 226 return extras
@@ -1,201 +1,209 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 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 from StringIO import StringIO
22 22
23 23 import pytest
24 24 from mock import patch, Mock
25 25
26 26 from rhodecode.lib.middleware.simplesvn import SimpleSvn, SimpleSvnApp
27 27 from rhodecode.lib.utils import get_rhodecode_base_path
28 28
29 29
30 30 class TestSimpleSvn(object):
31 31 @pytest.fixture(autouse=True)
32 32 def simple_svn(self, baseapp, request_stub):
33 33 base_path = get_rhodecode_base_path()
34 34 self.app = SimpleSvn(
35 35 config={'auth_ret_code': '', 'base_path': base_path},
36 36 registry=request_stub.registry)
37 37
38 38 def test_get_config(self):
39 39 extras = {'foo': 'FOO', 'bar': 'BAR'}
40 40 config = self.app._create_config(extras, repo_name='test-repo')
41 41 assert config == extras
42 42
43 43 @pytest.mark.parametrize(
44 44 'method', ['OPTIONS', 'PROPFIND', 'GET', 'REPORT'])
45 45 def test_get_action_returns_pull(self, method):
46 46 environment = {'REQUEST_METHOD': method}
47 47 action = self.app._get_action(environment)
48 48 assert action == 'pull'
49 49
50 50 @pytest.mark.parametrize(
51 51 'method', [
52 52 'MKACTIVITY', 'PROPPATCH', 'PUT', 'CHECKOUT', 'MKCOL', 'MOVE',
53 53 'COPY', 'DELETE', 'LOCK', 'UNLOCK', 'MERGE'
54 54 ])
55 55 def test_get_action_returns_push(self, method):
56 56 environment = {'REQUEST_METHOD': method}
57 57 action = self.app._get_action(environment)
58 58 assert action == 'push'
59 59
60 60 @pytest.mark.parametrize(
61 61 'path, expected_name', [
62 62 ('/hello-svn', 'hello-svn'),
63 63 ('/hello-svn/', 'hello-svn'),
64 64 ('/group/hello-svn/', 'group/hello-svn'),
65 65 ('/group/hello-svn/!svn/vcc/default', 'group/hello-svn'),
66 66 ])
67 67 def test_get_repository_name(self, path, expected_name):
68 68 environment = {'PATH_INFO': path}
69 69 name = self.app._get_repository_name(environment)
70 70 assert name == expected_name
71 71
72 72 def test_get_repository_name_subfolder(self, backend_svn):
73 73 repo = backend_svn.repo
74 74 environment = {
75 75 'PATH_INFO': '/{}/path/with/subfolders'.format(repo.repo_name)}
76 76 name = self.app._get_repository_name(environment)
77 77 assert name == repo.repo_name
78 78
79 79 def test_create_wsgi_app(self):
80 80 with patch.object(SimpleSvn, '_is_svn_enabled') as mock_method:
81 81 mock_method.return_value = False
82 82 with patch('rhodecode.lib.middleware.simplesvn.DisabledSimpleSvnApp') as (
83 83 wsgi_app_mock):
84 84 config = Mock()
85 85 wsgi_app = self.app._create_wsgi_app(
86 86 repo_path='', repo_name='', config=config)
87 87
88 88 wsgi_app_mock.assert_called_once_with(config)
89 89 assert wsgi_app == wsgi_app_mock()
90 90
91 91 def test_create_wsgi_app_when_enabled(self):
92 92 with patch.object(SimpleSvn, '_is_svn_enabled') as mock_method:
93 93 mock_method.return_value = True
94 94 with patch('rhodecode.lib.middleware.simplesvn.SimpleSvnApp') as (
95 95 wsgi_app_mock):
96 96 config = Mock()
97 97 wsgi_app = self.app._create_wsgi_app(
98 98 repo_path='', repo_name='', config=config)
99 99
100 100 wsgi_app_mock.assert_called_once_with(config)
101 101 assert wsgi_app == wsgi_app_mock()
102 102
103 103
104 104 class TestSimpleSvnApp(object):
105 105 data = '<xml></xml>'
106 106 path = '/group/my-repo'
107 107 wsgi_input = StringIO(data)
108 108 environment = {
109 109 'HTTP_DAV': (
110 110 'http://subversion.tigris.org/xmlns/dav/svn/depth,'
111 111 ' http://subversion.tigris.org/xmlns/dav/svn/mergeinfo'),
112 112 'HTTP_USER_AGENT': 'SVN/1.8.11 (x86_64-linux) serf/1.3.8',
113 113 'REQUEST_METHOD': 'OPTIONS',
114 114 'PATH_INFO': path,
115 115 'wsgi.input': wsgi_input,
116 116 'CONTENT_TYPE': 'text/xml',
117 117 'CONTENT_LENGTH': '130'
118 118 }
119 119
120 120 def setup_method(self, method):
121 121 self.host = 'http://localhost/'
122 122 base_path = get_rhodecode_base_path()
123 123 self.app = SimpleSvnApp(
124 124 config={'subversion_http_server_url': self.host,
125 125 'base_path': base_path})
126 126
127 127 def test_get_request_headers_with_content_type(self):
128 128 expected_headers = {
129 129 'Dav': self.environment['HTTP_DAV'],
130 130 'User-Agent': self.environment['HTTP_USER_AGENT'],
131 131 'Content-Type': self.environment['CONTENT_TYPE'],
132 132 'Content-Length': self.environment['CONTENT_LENGTH']
133 133 }
134 134 headers = self.app._get_request_headers(self.environment)
135 135 assert headers == expected_headers
136 136
137 137 def test_get_request_headers_without_content_type(self):
138 138 environment = self.environment.copy()
139 139 environment.pop('CONTENT_TYPE')
140 140 expected_headers = {
141 141 'Dav': environment['HTTP_DAV'],
142 142 'Content-Length': self.environment['CONTENT_LENGTH'],
143 143 'User-Agent': environment['HTTP_USER_AGENT'],
144 144 }
145 145 request_headers = self.app._get_request_headers(environment)
146 146 assert request_headers == expected_headers
147 147
148 148 def test_get_response_headers(self):
149 149 headers = {
150 150 'Connection': 'keep-alive',
151 151 'Keep-Alive': 'timeout=5, max=100',
152 152 'Transfer-Encoding': 'chunked',
153 153 'Content-Encoding': 'gzip',
154 154 'MS-Author-Via': 'DAV',
155 155 'SVN-Supported-Posts': 'create-txn-with-props'
156 156 }
157 157 expected_headers = [
158 158 ('MS-Author-Via', 'DAV'),
159 159 ('SVN-Supported-Posts', 'create-txn-with-props'),
160 160 ]
161 161 response_headers = self.app._get_response_headers(headers)
162 162 assert sorted(response_headers) == sorted(expected_headers)
163 163
164 def test_get_url(self):
165 url = self.app._get_url(self.path)
166 expected_url = '{}{}'.format(self.host.strip('/'), self.path)
164 @pytest.mark.parametrize('svn_http_url, path_info, expected_url', [
165 ('http://localhost:8200', '/repo_name', 'http://localhost:8200/repo_name'),
166 ('http://localhost:8200///', '/repo_name', 'http://localhost:8200/repo_name'),
167 ('http://localhost:8200', '/group/repo_name', 'http://localhost:8200/group/repo_name'),
168 ('http://localhost:8200/', '/group/repo_name', 'http://localhost:8200/group/repo_name'),
169 ('http://localhost:8200/prefix', '/repo_name', 'http://localhost:8200/prefix/repo_name'),
170 ('http://localhost:8200/prefix', 'repo_name', 'http://localhost:8200/prefix/repo_name'),
171 ('http://localhost:8200/prefix', '/group/repo_name', 'http://localhost:8200/prefix/group/repo_name')
172 ])
173 def test_get_url(self, svn_http_url, path_info, expected_url):
174 url = self.app._get_url(svn_http_url, path_info)
167 175 assert url == expected_url
168 176
169 177 def test_call(self):
170 178 start_response = Mock()
171 179 response_mock = Mock()
172 180 response_mock.headers = {
173 181 'Content-Encoding': 'gzip',
174 182 'MS-Author-Via': 'DAV',
175 183 'SVN-Supported-Posts': 'create-txn-with-props'
176 184 }
177 185 response_mock.status_code = 200
178 186 response_mock.reason = 'OK'
179 187 with patch('rhodecode.lib.middleware.simplesvn.requests.request') as (
180 188 request_mock):
181 189 request_mock.return_value = response_mock
182 190 self.app(self.environment, start_response)
183 191
184 192 expected_url = '{}{}'.format(self.host.strip('/'), self.path)
185 193 expected_request_headers = {
186 194 'Dav': self.environment['HTTP_DAV'],
187 195 'User-Agent': self.environment['HTTP_USER_AGENT'],
188 196 'Content-Type': self.environment['CONTENT_TYPE'],
189 197 'Content-Length': self.environment['CONTENT_LENGTH']
190 198 }
191 199 expected_response_headers = [
192 200 ('SVN-Supported-Posts', 'create-txn-with-props'),
193 201 ('MS-Author-Via', 'DAV'),
194 202 ]
195 203 request_mock.assert_called_once_with(
196 204 self.environment['REQUEST_METHOD'], expected_url,
197 205 data=self.data, headers=expected_request_headers, stream=False)
198 206 response_mock.iter_content.assert_called_once_with(chunk_size=1024)
199 207 args, _ = start_response.call_args
200 208 assert args[0] == '200 OK'
201 209 assert sorted(args[1]) == sorted(expected_response_headers)
General Comments 0
You need to be logged in to leave comments. Login now