##// END OF EJS Templates
caches: fix import of compute_key_from_params function.
marcink -
r2970:20adf10a default
parent child Browse files
Show More
@@ -1,313 +1,312 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 os
22 22 import time
23 23 import logging
24 24 import tempfile
25 25 import traceback
26 26 import threading
27 27
28 28 from BaseHTTPServer import BaseHTTPRequestHandler
29 29 from SocketServer import TCPServer
30 30
31 31 import rhodecode
32 32 from rhodecode.model import meta
33 33 from rhodecode.lib.base import bootstrap_request, bootstrap_config
34 34 from rhodecode.lib import hooks_base
35 35 from rhodecode.lib.utils2 import AttributeDict
36 36 from rhodecode.lib.ext_json import json
37
37 from rhodecode.lib import rc_cache
38 38
39 39 log = logging.getLogger(__name__)
40 40
41 41
42 42 class HooksHttpHandler(BaseHTTPRequestHandler):
43 43
44 44 def do_POST(self):
45 45 method, extras = self._read_request()
46 46 txn_id = getattr(self.server, 'txn_id', None)
47 47 if txn_id:
48 from rhodecode.lib.caches import compute_key_from_params
49 48 log.debug('Computing TXN_ID based on `%s`:`%s`',
50 49 extras['repository'], extras['txn_id'])
51 computed_txn_id = compute_key_from_params(
50 computed_txn_id = rc_cache.utils.compute_key_from_params(
52 51 extras['repository'], extras['txn_id'])
53 52 if txn_id != computed_txn_id:
54 53 raise Exception(
55 54 'TXN ID fail: expected {} got {} instead'.format(
56 55 txn_id, computed_txn_id))
57 56
58 57 try:
59 58 result = self._call_hook(method, extras)
60 59 except Exception as e:
61 60 exc_tb = traceback.format_exc()
62 61 result = {
63 62 'exception': e.__class__.__name__,
64 63 'exception_traceback': exc_tb,
65 64 'exception_args': e.args
66 65 }
67 66 self._write_response(result)
68 67
69 68 def _read_request(self):
70 69 length = int(self.headers['Content-Length'])
71 70 body = self.rfile.read(length).decode('utf-8')
72 71 data = json.loads(body)
73 72 return data['method'], data['extras']
74 73
75 74 def _write_response(self, result):
76 75 self.send_response(200)
77 76 self.send_header("Content-type", "text/json")
78 77 self.end_headers()
79 78 self.wfile.write(json.dumps(result))
80 79
81 80 def _call_hook(self, method, extras):
82 81 hooks = Hooks()
83 82 try:
84 83 result = getattr(hooks, method)(extras)
85 84 finally:
86 85 meta.Session.remove()
87 86 return result
88 87
89 88 def log_message(self, format, *args):
90 89 """
91 90 This is an overridden method of BaseHTTPRequestHandler which logs using
92 91 logging library instead of writing directly to stderr.
93 92 """
94 93
95 94 message = format % args
96 95
97 96 log.debug(
98 97 "%s - - [%s] %s", self.client_address[0],
99 98 self.log_date_time_string(), message)
100 99
101 100
102 101 class DummyHooksCallbackDaemon(object):
103 102 hooks_uri = ''
104 103
105 104 def __init__(self):
106 105 self.hooks_module = Hooks.__module__
107 106
108 107 def __enter__(self):
109 108 log.debug('Running dummy hooks callback daemon')
110 109 return self
111 110
112 111 def __exit__(self, exc_type, exc_val, exc_tb):
113 112 log.debug('Exiting dummy hooks callback daemon')
114 113
115 114
116 115 class ThreadedHookCallbackDaemon(object):
117 116
118 117 _callback_thread = None
119 118 _daemon = None
120 119 _done = False
121 120
122 121 def __init__(self, txn_id=None, host=None, port=None):
123 122 self._prepare(txn_id=txn_id, host=None, port=port)
124 123
125 124 def __enter__(self):
126 125 self._run()
127 126 return self
128 127
129 128 def __exit__(self, exc_type, exc_val, exc_tb):
130 129 log.debug('Callback daemon exiting now...')
131 130 self._stop()
132 131
133 132 def _prepare(self, txn_id=None, host=None, port=None):
134 133 raise NotImplementedError()
135 134
136 135 def _run(self):
137 136 raise NotImplementedError()
138 137
139 138 def _stop(self):
140 139 raise NotImplementedError()
141 140
142 141
143 142 class HttpHooksCallbackDaemon(ThreadedHookCallbackDaemon):
144 143 """
145 144 Context manager which will run a callback daemon in a background thread.
146 145 """
147 146
148 147 hooks_uri = None
149 148
150 149 # From Python docs: Polling reduces our responsiveness to a shutdown
151 150 # request and wastes cpu at all other times.
152 151 POLL_INTERVAL = 0.01
153 152
154 153 def _prepare(self, txn_id=None, host=None, port=None):
155 154 host = host or '127.0.0.1'
156 155 self._done = False
157 156 self._daemon = TCPServer((host, port or 0), HooksHttpHandler)
158 157 _, port = self._daemon.server_address
159 158 self.hooks_uri = '{}:{}'.format(host, port)
160 159 self.txn_id = txn_id
161 160 # inject transaction_id for later verification
162 161 self._daemon.txn_id = self.txn_id
163 162
164 163 log.debug(
165 164 "Preparing HTTP callback daemon at `%s` and registering hook object",
166 165 self.hooks_uri)
167 166
168 167 def _run(self):
169 168 log.debug("Running event loop of callback daemon in background thread")
170 169 callback_thread = threading.Thread(
171 170 target=self._daemon.serve_forever,
172 171 kwargs={'poll_interval': self.POLL_INTERVAL})
173 172 callback_thread.daemon = True
174 173 callback_thread.start()
175 174 self._callback_thread = callback_thread
176 175
177 176 def _stop(self):
178 177 log.debug("Waiting for background thread to finish.")
179 178 self._daemon.shutdown()
180 179 self._callback_thread.join()
181 180 self._daemon = None
182 181 self._callback_thread = None
183 182 if self.txn_id:
184 183 txn_id_file = get_txn_id_data_path(self.txn_id)
185 184 log.debug('Cleaning up TXN ID %s', txn_id_file)
186 185 if os.path.isfile(txn_id_file):
187 186 os.remove(txn_id_file)
188 187
189 188 log.debug("Background thread done.")
190 189
191 190
192 191 def get_txn_id_data_path(txn_id):
193 192 root = tempfile.gettempdir()
194 193 return os.path.join(root, 'rc_txn_id_{}'.format(txn_id))
195 194
196 195
197 196 def store_txn_id_data(txn_id, data_dict):
198 197 if not txn_id:
199 198 log.warning('Cannot store txn_id because it is empty')
200 199 return
201 200
202 201 path = get_txn_id_data_path(txn_id)
203 202 try:
204 203 with open(path, 'wb') as f:
205 204 f.write(json.dumps(data_dict))
206 205 except Exception:
207 206 log.exception('Failed to write txn_id metadata')
208 207
209 208
210 209 def get_txn_id_from_store(txn_id):
211 210 """
212 211 Reads txn_id from store and if present returns the data for callback manager
213 212 """
214 213 path = get_txn_id_data_path(txn_id)
215 214 try:
216 215 with open(path, 'rb') as f:
217 216 return json.loads(f.read())
218 217 except Exception:
219 218 return {}
220 219
221 220
222 221 def prepare_callback_daemon(extras, protocol, host, use_direct_calls, txn_id=None):
223 222 txn_details = get_txn_id_from_store(txn_id)
224 223 port = txn_details.get('port', 0)
225 224 if use_direct_calls:
226 225 callback_daemon = DummyHooksCallbackDaemon()
227 226 extras['hooks_module'] = callback_daemon.hooks_module
228 227 else:
229 228 if protocol == 'http':
230 229 callback_daemon = HttpHooksCallbackDaemon(
231 230 txn_id=txn_id, host=host, port=port)
232 231 else:
233 232 log.error('Unsupported callback daemon protocol "%s"', protocol)
234 233 raise Exception('Unsupported callback daemon protocol.')
235 234
236 235 extras['hooks_uri'] = callback_daemon.hooks_uri
237 236 extras['hooks_protocol'] = protocol
238 237 extras['time'] = time.time()
239 238
240 239 # register txn_id
241 240 extras['txn_id'] = txn_id
242 241
243 242 log.debug('Prepared a callback daemon: %s at url `%s`',
244 243 callback_daemon.__class__.__name__, callback_daemon.hooks_uri)
245 244 return callback_daemon, extras
246 245
247 246
248 247 class Hooks(object):
249 248 """
250 249 Exposes the hooks for remote call backs
251 250 """
252 251
253 252 def repo_size(self, extras):
254 253 log.debug("Called repo_size of %s object", self)
255 254 return self._call_hook(hooks_base.repo_size, extras)
256 255
257 256 def pre_pull(self, extras):
258 257 log.debug("Called pre_pull of %s object", self)
259 258 return self._call_hook(hooks_base.pre_pull, extras)
260 259
261 260 def post_pull(self, extras):
262 261 log.debug("Called post_pull of %s object", self)
263 262 return self._call_hook(hooks_base.post_pull, extras)
264 263
265 264 def pre_push(self, extras):
266 265 log.debug("Called pre_push of %s object", self)
267 266 return self._call_hook(hooks_base.pre_push, extras)
268 267
269 268 def post_push(self, extras):
270 269 log.debug("Called post_push of %s object", self)
271 270 return self._call_hook(hooks_base.post_push, extras)
272 271
273 272 def _call_hook(self, hook, extras):
274 273 extras = AttributeDict(extras)
275 274 server_url = extras['server_url']
276 275 request = bootstrap_request(application_url=server_url)
277 276
278 277 bootstrap_config(request) # inject routes and other interfaces
279 278
280 279 # inject the user for usage in hooks
281 280 request.user = AttributeDict({'username': extras.username,
282 281 'ip_addr': extras.ip,
283 282 'user_id': extras.user_id})
284 283
285 284 extras.request = request
286 285
287 286 try:
288 287 result = hook(extras)
289 288 except Exception as error:
290 289 exc_tb = traceback.format_exc()
291 290 log.exception('Exception when handling hook %s', hook)
292 291 error_args = error.args
293 292 return {
294 293 'status': 128,
295 294 'output': '',
296 295 'exception': type(error).__name__,
297 296 'exception_traceback': exc_tb,
298 297 'exception_args': error_args,
299 298 }
300 299 finally:
301 300 meta.Session.remove()
302 301
303 302 log.debug('Got hook call response %s', result)
304 303 return {
305 304 'status': result.status,
306 305 'output': result.output,
307 306 }
308 307
309 308 def __enter__(self):
310 309 return self
311 310
312 311 def __exit__(self, exc_type, exc_val, exc_tb):
313 312 pass
@@ -1,204 +1,204 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 from urlparse import urljoin
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
52 52 data = environ['wsgi.input']
53 53 # johbo: Avoid that we end up with sending the request in chunked
54 54 # transfer encoding (mainly on Gunicorn). If we know the content
55 55 # length, then we should transfer the payload in one request.
56 56 if environ['REQUEST_METHOD'] == 'MKCOL' or 'CONTENT_LENGTH' in environ:
57 57 data = data.read()
58 58 if data.startswith('(create-txn-with-props'):
59 59 # store on-the-fly our rc_extra using svn revision properties
60 60 # those can be read later on in hooks executed so we have a way
61 61 # to pass in the data into svn hooks
62 62 rc_data = base64.urlsafe_b64encode(json.dumps(self.rc_extras))
63 63 rc_data_len = len(rc_data)
64 64 # header defines data lenght, and serialized data
65 65 skel = ' rc-scm-extras {} {}'.format(rc_data_len, rc_data)
66 66 data = data[:-2] + skel + '))'
67 67
68 68 log.debug('Calling: %s method via `%s`', environ['REQUEST_METHOD'],
69 69 self._get_url(environ['PATH_INFO']))
70 70
71 71 response = requests.request(
72 72 environ['REQUEST_METHOD'], self._get_url(environ['PATH_INFO']),
73 73 data=data, headers=request_headers)
74 74
75 75 if response.status_code not in [200, 401]:
76 76 if response.status_code >= 500:
77 77 log.error('Got SVN response:%s with text:`%s`',
78 78 response, response.text)
79 79 else:
80 80 log.debug('Got SVN response:%s with text:`%s`',
81 81 response, response.text)
82 82 else:
83 83 log.debug('got response code: %s', response.status_code)
84 84
85 85 response_headers = self._get_response_headers(response.headers)
86 86
87 87 if response.headers.get('SVN-Txn-name'):
88 88 svn_tx_id = response.headers.get('SVN-Txn-name')
89 txn_id = rc_cache.compute_key_from_params(
89 txn_id = rc_cache.utils.compute_key_from_params(
90 90 self.config['repository'], svn_tx_id)
91 91 port = safe_int(self.rc_extras['hooks_uri'].split(':')[-1])
92 92 store_txn_id_data(txn_id, {'port': port})
93 93
94 94 start_response(
95 95 '{} {}'.format(response.status_code, response.reason),
96 96 response_headers)
97 97 return response.iter_content(chunk_size=1024)
98 98
99 99 def _get_url(self, path):
100 100 url_path = urljoin(
101 101 self.config.get('subversion_http_server_url', ''), path)
102 102 url_path = urllib.quote(url_path, safe="/:=~+!$,;'")
103 103 return url_path
104 104
105 105 def _get_request_headers(self, environ):
106 106 headers = {}
107 107
108 108 for key in environ:
109 109 if not key.startswith('HTTP_'):
110 110 continue
111 111 new_key = key.split('_')
112 112 new_key = [k.capitalize() for k in new_key[1:]]
113 113 new_key = '-'.join(new_key)
114 114 headers[new_key] = environ[key]
115 115
116 116 if 'CONTENT_TYPE' in environ:
117 117 headers['Content-Type'] = environ['CONTENT_TYPE']
118 118
119 119 if 'CONTENT_LENGTH' in environ:
120 120 headers['Content-Length'] = environ['CONTENT_LENGTH']
121 121
122 122 return headers
123 123
124 124 def _get_response_headers(self, headers):
125 125 headers = [
126 126 (h, headers[h])
127 127 for h in headers
128 128 if h.lower() not in self.IGNORED_HEADERS
129 129 ]
130 130
131 131 return headers
132 132
133 133
134 134 class DisabledSimpleSvnApp(object):
135 135 def __init__(self, config):
136 136 self.config = config
137 137
138 138 def __call__(self, environ, start_response):
139 139 reason = 'Cannot handle SVN call because: SVN HTTP Proxy is not enabled'
140 140 log.warning(reason)
141 141 return HTTPNotAcceptable(reason)(environ, start_response)
142 142
143 143
144 144 class SimpleSvn(simplevcs.SimpleVCS):
145 145
146 146 SCM = 'svn'
147 147 READ_ONLY_COMMANDS = ('OPTIONS', 'PROPFIND', 'GET', 'REPORT')
148 148 DEFAULT_HTTP_SERVER = 'http://localhost:8090'
149 149
150 150 def _get_repository_name(self, environ):
151 151 """
152 152 Gets repository name out of PATH_INFO header
153 153
154 154 :param environ: environ where PATH_INFO is stored
155 155 """
156 156 path = environ['PATH_INFO'].split('!')
157 157 repo_name = path[0].strip('/')
158 158
159 159 # SVN includes the whole path in it's requests, including
160 160 # subdirectories inside the repo. Therefore we have to search for
161 161 # the repo root directory.
162 162 if not is_valid_repo(
163 163 repo_name, self.base_path, explicit_scm=self.SCM):
164 164 current_path = ''
165 165 for component in repo_name.split('/'):
166 166 current_path += component
167 167 if is_valid_repo(
168 168 current_path, self.base_path, explicit_scm=self.SCM):
169 169 return current_path
170 170 current_path += '/'
171 171
172 172 return repo_name
173 173
174 174 def _get_action(self, environ):
175 175 return (
176 176 'pull'
177 177 if environ['REQUEST_METHOD'] in self.READ_ONLY_COMMANDS
178 178 else 'push')
179 179
180 180 def _should_use_callback_daemon(self, extras, environ, action):
181 181 # only MERGE command triggers hooks, so we don't want to start
182 182 # hooks server too many times. POST however starts the svn transaction
183 183 # so we also need to run the init of callback daemon of POST
184 184 if environ['REQUEST_METHOD'] in ['MERGE', 'POST']:
185 185 return True
186 186 return False
187 187
188 188 def _create_wsgi_app(self, repo_path, repo_name, config):
189 189 if self._is_svn_enabled():
190 190 return SimpleSvnApp(config)
191 191 # we don't have http proxy enabled return dummy request handler
192 192 return DisabledSimpleSvnApp(config)
193 193
194 194 def _is_svn_enabled(self):
195 195 conf = self.repo_vcs_config
196 196 return str2bool(conf.get('vcs_svn_proxy', 'http_requests_enabled'))
197 197
198 198 def _create_config(self, extras, repo_name):
199 199 conf = self.repo_vcs_config
200 200 server_url = conf.get('vcs_svn_proxy', 'http_server_url')
201 201 server_url = server_url or self.DEFAULT_HTTP_SERVER
202 202
203 203 extras['subversion_http_server_url'] = server_url
204 204 return extras
@@ -1,661 +1,661 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-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 """
22 22 SimpleVCS middleware for handling protocol request (push/clone etc.)
23 23 It's implemented with basic auth function
24 24 """
25 25
26 26 import os
27 27 import re
28 28 import logging
29 29 import importlib
30 30 from functools import wraps
31 31 from StringIO import StringIO
32 32 from lxml import etree
33 33
34 34 import time
35 35 from paste.httpheaders import REMOTE_USER, AUTH_TYPE
36 36
37 37 from pyramid.httpexceptions import (
38 38 HTTPNotFound, HTTPForbidden, HTTPNotAcceptable, HTTPInternalServerError)
39 39 from zope.cachedescriptors.property import Lazy as LazyProperty
40 40
41 41 import rhodecode
42 42 from rhodecode.authentication.base import authenticate, VCS_TYPE, loadplugin
43 43 from rhodecode.lib import rc_cache
44 44 from rhodecode.lib.auth import AuthUser, HasPermissionAnyMiddleware
45 45 from rhodecode.lib.base import (
46 46 BasicAuth, get_ip_addr, get_user_agent, vcs_operation_context)
47 47 from rhodecode.lib.exceptions import (UserCreationError, NotAllowedToCreateUserError)
48 48 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
49 49 from rhodecode.lib.middleware import appenlight
50 50 from rhodecode.lib.middleware.utils import scm_app_http
51 51 from rhodecode.lib.utils import is_valid_repo, SLUG_RE
52 52 from rhodecode.lib.utils2 import safe_str, fix_PATH, str2bool, safe_unicode
53 53 from rhodecode.lib.vcs.conf import settings as vcs_settings
54 54 from rhodecode.lib.vcs.backends import base
55 55
56 56 from rhodecode.model import meta
57 57 from rhodecode.model.db import User, Repository, PullRequest
58 58 from rhodecode.model.scm import ScmModel
59 59 from rhodecode.model.pull_request import PullRequestModel
60 60 from rhodecode.model.settings import SettingsModel, VcsSettingsModel
61 61
62 62 log = logging.getLogger(__name__)
63 63
64 64
65 65 def extract_svn_txn_id(acl_repo_name, data):
66 66 """
67 Helper method for extraction of svn txn_id from submited XML data during
67 Helper method for extraction of svn txn_id from submitted XML data during
68 68 POST operations
69 69 """
70 70 try:
71 71 root = etree.fromstring(data)
72 72 pat = re.compile(r'/txn/(?P<txn_id>.*)')
73 73 for el in root:
74 74 if el.tag == '{DAV:}source':
75 75 for sub_el in el:
76 76 if sub_el.tag == '{DAV:}href':
77 77 match = pat.search(sub_el.text)
78 78 if match:
79 79 svn_tx_id = match.groupdict()['txn_id']
80 txn_id = rc_cache.compute_key_from_params(
80 txn_id = rc_cache.utils.compute_key_from_params(
81 81 acl_repo_name, svn_tx_id)
82 82 return txn_id
83 83 except Exception:
84 84 log.exception('Failed to extract txn_id')
85 85
86 86
87 87 def initialize_generator(factory):
88 88 """
89 89 Initializes the returned generator by draining its first element.
90 90
91 91 This can be used to give a generator an initializer, which is the code
92 92 up to the first yield statement. This decorator enforces that the first
93 93 produced element has the value ``"__init__"`` to make its special
94 94 purpose very explicit in the using code.
95 95 """
96 96
97 97 @wraps(factory)
98 98 def wrapper(*args, **kwargs):
99 99 gen = factory(*args, **kwargs)
100 100 try:
101 101 init = gen.next()
102 102 except StopIteration:
103 103 raise ValueError('Generator must yield at least one element.')
104 104 if init != "__init__":
105 105 raise ValueError('First yielded element must be "__init__".')
106 106 return gen
107 107 return wrapper
108 108
109 109
110 110 class SimpleVCS(object):
111 111 """Common functionality for SCM HTTP handlers."""
112 112
113 113 SCM = 'unknown'
114 114
115 115 acl_repo_name = None
116 116 url_repo_name = None
117 117 vcs_repo_name = None
118 118 rc_extras = {}
119 119
120 120 # We have to handle requests to shadow repositories different than requests
121 121 # to normal repositories. Therefore we have to distinguish them. To do this
122 122 # we use this regex which will match only on URLs pointing to shadow
123 123 # repositories.
124 124 shadow_repo_re = re.compile(
125 125 '(?P<groups>(?:{slug_pat}/)*)' # repo groups
126 126 '(?P<target>{slug_pat})/' # target repo
127 127 'pull-request/(?P<pr_id>\d+)/' # pull request
128 128 'repository$' # shadow repo
129 129 .format(slug_pat=SLUG_RE.pattern))
130 130
131 131 def __init__(self, config, registry):
132 132 self.registry = registry
133 133 self.config = config
134 134 # re-populated by specialized middleware
135 135 self.repo_vcs_config = base.Config()
136 136 self.rhodecode_settings = SettingsModel().get_all_settings(cache=True)
137 137
138 138 registry.rhodecode_settings = self.rhodecode_settings
139 139 # authenticate this VCS request using authfunc
140 140 auth_ret_code_detection = \
141 141 str2bool(self.config.get('auth_ret_code_detection', False))
142 142 self.authenticate = BasicAuth(
143 143 '', authenticate, registry, config.get('auth_ret_code'),
144 144 auth_ret_code_detection)
145 145 self.ip_addr = '0.0.0.0'
146 146
147 147 @LazyProperty
148 148 def global_vcs_config(self):
149 149 try:
150 150 return VcsSettingsModel().get_ui_settings_as_config_obj()
151 151 except Exception:
152 152 return base.Config()
153 153
154 154 @property
155 155 def base_path(self):
156 156 settings_path = self.repo_vcs_config.get(
157 157 *VcsSettingsModel.PATH_SETTING)
158 158
159 159 if not settings_path:
160 160 settings_path = self.global_vcs_config.get(
161 161 *VcsSettingsModel.PATH_SETTING)
162 162
163 163 if not settings_path:
164 164 # try, maybe we passed in explicitly as config option
165 165 settings_path = self.config.get('base_path')
166 166
167 167 if not settings_path:
168 168 raise ValueError('FATAL: base_path is empty')
169 169 return settings_path
170 170
171 171 def set_repo_names(self, environ):
172 172 """
173 173 This will populate the attributes acl_repo_name, url_repo_name,
174 174 vcs_repo_name and is_shadow_repo. In case of requests to normal (non
175 175 shadow) repositories all names are equal. In case of requests to a
176 176 shadow repository the acl-name points to the target repo of the pull
177 177 request and the vcs-name points to the shadow repo file system path.
178 178 The url-name is always the URL used by the vcs client program.
179 179
180 180 Example in case of a shadow repo:
181 181 acl_repo_name = RepoGroup/MyRepo
182 182 url_repo_name = RepoGroup/MyRepo/pull-request/3/repository
183 183 vcs_repo_name = /repo/base/path/RepoGroup/.__shadow_MyRepo_pr-3'
184 184 """
185 185 # First we set the repo name from URL for all attributes. This is the
186 186 # default if handling normal (non shadow) repo requests.
187 187 self.url_repo_name = self._get_repository_name(environ)
188 188 self.acl_repo_name = self.vcs_repo_name = self.url_repo_name
189 189 self.is_shadow_repo = False
190 190
191 191 # Check if this is a request to a shadow repository.
192 192 match = self.shadow_repo_re.match(self.url_repo_name)
193 193 if match:
194 194 match_dict = match.groupdict()
195 195
196 196 # Build acl repo name from regex match.
197 197 acl_repo_name = safe_unicode('{groups}{target}'.format(
198 198 groups=match_dict['groups'] or '',
199 199 target=match_dict['target']))
200 200
201 201 # Retrieve pull request instance by ID from regex match.
202 202 pull_request = PullRequest.get(match_dict['pr_id'])
203 203
204 204 # Only proceed if we got a pull request and if acl repo name from
205 205 # URL equals the target repo name of the pull request.
206 206 if pull_request and \
207 207 (acl_repo_name == pull_request.target_repo.repo_name):
208 208 repo_id = pull_request.target_repo.repo_id
209 209 # Get file system path to shadow repository.
210 210 workspace_id = PullRequestModel()._workspace_id(pull_request)
211 211 target_vcs = pull_request.target_repo.scm_instance()
212 212 vcs_repo_name = target_vcs._get_shadow_repository_path(
213 213 repo_id, workspace_id)
214 214
215 215 # Store names for later usage.
216 216 self.vcs_repo_name = vcs_repo_name
217 217 self.acl_repo_name = acl_repo_name
218 218 self.is_shadow_repo = True
219 219
220 220 log.debug('Setting all VCS repository names: %s', {
221 221 'acl_repo_name': self.acl_repo_name,
222 222 'url_repo_name': self.url_repo_name,
223 223 'vcs_repo_name': self.vcs_repo_name,
224 224 })
225 225
226 226 @property
227 227 def scm_app(self):
228 228 custom_implementation = self.config['vcs.scm_app_implementation']
229 229 if custom_implementation == 'http':
230 230 log.info('Using HTTP implementation of scm app.')
231 231 scm_app_impl = scm_app_http
232 232 else:
233 233 log.info('Using custom implementation of scm_app: "{}"'.format(
234 234 custom_implementation))
235 235 scm_app_impl = importlib.import_module(custom_implementation)
236 236 return scm_app_impl
237 237
238 238 def _get_by_id(self, repo_name):
239 239 """
240 240 Gets a special pattern _<ID> from clone url and tries to replace it
241 241 with a repository_name for support of _<ID> non changeable urls
242 242 """
243 243
244 244 data = repo_name.split('/')
245 245 if len(data) >= 2:
246 246 from rhodecode.model.repo import RepoModel
247 247 by_id_match = RepoModel().get_repo_by_id(repo_name)
248 248 if by_id_match:
249 249 data[1] = by_id_match.repo_name
250 250
251 251 return safe_str('/'.join(data))
252 252
253 253 def _invalidate_cache(self, repo_name):
254 254 """
255 255 Set's cache for this repository for invalidation on next access
256 256
257 257 :param repo_name: full repo name, also a cache key
258 258 """
259 259 ScmModel().mark_for_invalidation(repo_name)
260 260
261 261 def is_valid_and_existing_repo(self, repo_name, base_path, scm_type):
262 262 db_repo = Repository.get_by_repo_name(repo_name)
263 263 if not db_repo:
264 264 log.debug('Repository `%s` not found inside the database.',
265 265 repo_name)
266 266 return False
267 267
268 268 if db_repo.repo_type != scm_type:
269 269 log.warning(
270 270 'Repository `%s` have incorrect scm_type, expected %s got %s',
271 271 repo_name, db_repo.repo_type, scm_type)
272 272 return False
273 273
274 274 config = db_repo._config
275 275 config.set('extensions', 'largefiles', '')
276 276 return is_valid_repo(
277 277 repo_name, base_path,
278 278 explicit_scm=scm_type, expect_scm=scm_type, config=config)
279 279
280 280 def valid_and_active_user(self, user):
281 281 """
282 282 Checks if that user is not empty, and if it's actually object it checks
283 283 if he's active.
284 284
285 285 :param user: user object or None
286 286 :return: boolean
287 287 """
288 288 if user is None:
289 289 return False
290 290
291 291 elif user.active:
292 292 return True
293 293
294 294 return False
295 295
296 296 @property
297 297 def is_shadow_repo_dir(self):
298 298 return os.path.isdir(self.vcs_repo_name)
299 299
300 300 def _check_permission(self, action, user, repo_name, ip_addr=None,
301 301 plugin_id='', plugin_cache_active=False, cache_ttl=0):
302 302 """
303 303 Checks permissions using action (push/pull) user and repository
304 304 name. If plugin_cache and ttl is set it will use the plugin which
305 305 authenticated the user to store the cached permissions result for N
306 306 amount of seconds as in cache_ttl
307 307
308 308 :param action: push or pull action
309 309 :param user: user instance
310 310 :param repo_name: repository name
311 311 """
312 312
313 313 log.debug('AUTH_CACHE_TTL for permissions `%s` active: %s (TTL: %s)',
314 314 plugin_id, plugin_cache_active, cache_ttl)
315 315
316 316 user_id = user.user_id
317 317 cache_namespace_uid = 'cache_user_auth.{}'.format(user_id)
318 318 region = rc_cache.get_or_create_region('cache_perms', cache_namespace_uid)
319 319
320 320 @region.conditional_cache_on_arguments(namespace=cache_namespace_uid,
321 321 expiration_time=cache_ttl,
322 322 condition=plugin_cache_active)
323 323 def compute_perm_vcs(
324 324 cache_name, plugin_id, action, user_id, repo_name, ip_addr):
325 325
326 326 log.debug('auth: calculating permission access now...')
327 327 # check IP
328 328 inherit = user.inherit_default_permissions
329 329 ip_allowed = AuthUser.check_ip_allowed(
330 330 user_id, ip_addr, inherit_from_default=inherit)
331 331 if ip_allowed:
332 332 log.info('Access for IP:%s allowed', ip_addr)
333 333 else:
334 334 return False
335 335
336 336 if action == 'push':
337 337 perms = ('repository.write', 'repository.admin')
338 338 if not HasPermissionAnyMiddleware(*perms)(user, repo_name):
339 339 return False
340 340
341 341 else:
342 342 # any other action need at least read permission
343 343 perms = (
344 344 'repository.read', 'repository.write', 'repository.admin')
345 345 if not HasPermissionAnyMiddleware(*perms)(user, repo_name):
346 346 return False
347 347
348 348 return True
349 349
350 350 start = time.time()
351 351 log.debug('Running plugin `%s` permissions check', plugin_id)
352 352
353 353 # for environ based auth, password can be empty, but then the validation is
354 354 # on the server that fills in the env data needed for authentication
355 355 perm_result = compute_perm_vcs(
356 356 'vcs_permissions', plugin_id, action, user.user_id, repo_name, ip_addr)
357 357
358 358 auth_time = time.time() - start
359 359 log.debug('Permissions for plugin `%s` completed in %.3fs, '
360 360 'expiration time of fetched cache %.1fs.',
361 361 plugin_id, auth_time, cache_ttl)
362 362
363 363 return perm_result
364 364
365 365 def _check_ssl(self, environ, start_response):
366 366 """
367 367 Checks the SSL check flag and returns False if SSL is not present
368 368 and required True otherwise
369 369 """
370 370 org_proto = environ['wsgi._org_proto']
371 371 # check if we have SSL required ! if not it's a bad request !
372 372 require_ssl = str2bool(self.repo_vcs_config.get('web', 'push_ssl'))
373 373 if require_ssl and org_proto == 'http':
374 374 log.debug(
375 375 'Bad request: detected protocol is `%s` and '
376 376 'SSL/HTTPS is required.', org_proto)
377 377 return False
378 378 return True
379 379
380 380 def _get_default_cache_ttl(self):
381 381 # take AUTH_CACHE_TTL from the `rhodecode` auth plugin
382 382 plugin = loadplugin('egg:rhodecode-enterprise-ce#rhodecode')
383 383 plugin_settings = plugin.get_settings()
384 384 plugin_cache_active, cache_ttl = plugin.get_ttl_cache(
385 385 plugin_settings) or (False, 0)
386 386 return plugin_cache_active, cache_ttl
387 387
388 388 def __call__(self, environ, start_response):
389 389 try:
390 390 return self._handle_request(environ, start_response)
391 391 except Exception:
392 392 log.exception("Exception while handling request")
393 393 appenlight.track_exception(environ)
394 394 return HTTPInternalServerError()(environ, start_response)
395 395 finally:
396 396 meta.Session.remove()
397 397
398 398 def _handle_request(self, environ, start_response):
399 399
400 400 if not self._check_ssl(environ, start_response):
401 401 reason = ('SSL required, while RhodeCode was unable '
402 402 'to detect this as SSL request')
403 403 log.debug('User not allowed to proceed, %s', reason)
404 404 return HTTPNotAcceptable(reason)(environ, start_response)
405 405
406 406 if not self.url_repo_name:
407 407 log.warning('Repository name is empty: %s', self.url_repo_name)
408 408 # failed to get repo name, we fail now
409 409 return HTTPNotFound()(environ, start_response)
410 410 log.debug('Extracted repo name is %s', self.url_repo_name)
411 411
412 412 ip_addr = get_ip_addr(environ)
413 413 user_agent = get_user_agent(environ)
414 414 username = None
415 415
416 416 # skip passing error to error controller
417 417 environ['pylons.status_code_redirect'] = True
418 418
419 419 # ======================================================================
420 420 # GET ACTION PULL or PUSH
421 421 # ======================================================================
422 422 action = self._get_action(environ)
423 423
424 424 # ======================================================================
425 425 # Check if this is a request to a shadow repository of a pull request.
426 426 # In this case only pull action is allowed.
427 427 # ======================================================================
428 428 if self.is_shadow_repo and action != 'pull':
429 429 reason = 'Only pull action is allowed for shadow repositories.'
430 430 log.debug('User not allowed to proceed, %s', reason)
431 431 return HTTPNotAcceptable(reason)(environ, start_response)
432 432
433 433 # Check if the shadow repo actually exists, in case someone refers
434 434 # to it, and it has been deleted because of successful merge.
435 435 if self.is_shadow_repo and not self.is_shadow_repo_dir:
436 436 log.debug(
437 437 'Shadow repo detected, and shadow repo dir `%s` is missing',
438 438 self.is_shadow_repo_dir)
439 439 return HTTPNotFound()(environ, start_response)
440 440
441 441 # ======================================================================
442 442 # CHECK ANONYMOUS PERMISSION
443 443 # ======================================================================
444 444 if action in ['pull', 'push']:
445 445 anonymous_user = User.get_default_user()
446 446 username = anonymous_user.username
447 447 if anonymous_user.active:
448 448 plugin_cache_active, cache_ttl = self._get_default_cache_ttl()
449 449 # ONLY check permissions if the user is activated
450 450 anonymous_perm = self._check_permission(
451 451 action, anonymous_user, self.acl_repo_name, ip_addr,
452 452 plugin_id='anonymous_access',
453 453 plugin_cache_active=plugin_cache_active,
454 454 cache_ttl=cache_ttl,
455 455 )
456 456 else:
457 457 anonymous_perm = False
458 458
459 459 if not anonymous_user.active or not anonymous_perm:
460 460 if not anonymous_user.active:
461 461 log.debug('Anonymous access is disabled, running '
462 462 'authentication')
463 463
464 464 if not anonymous_perm:
465 465 log.debug('Not enough credentials to access this '
466 466 'repository as anonymous user')
467 467
468 468 username = None
469 469 # ==============================================================
470 470 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
471 471 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
472 472 # ==============================================================
473 473
474 474 # try to auth based on environ, container auth methods
475 475 log.debug('Running PRE-AUTH for container based authentication')
476 476 pre_auth = authenticate(
477 477 '', '', environ, VCS_TYPE, registry=self.registry,
478 478 acl_repo_name=self.acl_repo_name)
479 479 if pre_auth and pre_auth.get('username'):
480 480 username = pre_auth['username']
481 481 log.debug('PRE-AUTH got %s as username', username)
482 482 if pre_auth:
483 483 log.debug('PRE-AUTH successful from %s',
484 484 pre_auth.get('auth_data', {}).get('_plugin'))
485 485
486 486 # If not authenticated by the container, running basic auth
487 487 # before inject the calling repo_name for special scope checks
488 488 self.authenticate.acl_repo_name = self.acl_repo_name
489 489
490 490 plugin_cache_active, cache_ttl = False, 0
491 491 plugin = None
492 492 if not username:
493 493 self.authenticate.realm = self.authenticate.get_rc_realm()
494 494
495 495 try:
496 496 auth_result = self.authenticate(environ)
497 497 except (UserCreationError, NotAllowedToCreateUserError) as e:
498 498 log.error(e)
499 499 reason = safe_str(e)
500 500 return HTTPNotAcceptable(reason)(environ, start_response)
501 501
502 502 if isinstance(auth_result, dict):
503 503 AUTH_TYPE.update(environ, 'basic')
504 504 REMOTE_USER.update(environ, auth_result['username'])
505 505 username = auth_result['username']
506 506 plugin = auth_result.get('auth_data', {}).get('_plugin')
507 507 log.info(
508 508 'MAIN-AUTH successful for user `%s` from %s plugin',
509 509 username, plugin)
510 510
511 511 plugin_cache_active, cache_ttl = auth_result.get(
512 512 'auth_data', {}).get('_ttl_cache') or (False, 0)
513 513 else:
514 514 return auth_result.wsgi_application(
515 515 environ, start_response)
516 516
517 517 # ==============================================================
518 518 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
519 519 # ==============================================================
520 520 user = User.get_by_username(username)
521 521 if not self.valid_and_active_user(user):
522 522 return HTTPForbidden()(environ, start_response)
523 523 username = user.username
524 524 user_id = user.user_id
525 525
526 526 # check user attributes for password change flag
527 527 user_obj = user
528 528 if user_obj and user_obj.username != User.DEFAULT_USER and \
529 529 user_obj.user_data.get('force_password_change'):
530 530 reason = 'password change required'
531 531 log.debug('User not allowed to authenticate, %s', reason)
532 532 return HTTPNotAcceptable(reason)(environ, start_response)
533 533
534 534 # check permissions for this repository
535 535 perm = self._check_permission(
536 536 action, user, self.acl_repo_name, ip_addr,
537 537 plugin, plugin_cache_active, cache_ttl)
538 538 if not perm:
539 539 return HTTPForbidden()(environ, start_response)
540 540 environ['rc_auth_user_id'] = user_id
541 541
542 542 # extras are injected into UI object and later available
543 543 # in hooks executed by RhodeCode
544 544 check_locking = _should_check_locking(environ.get('QUERY_STRING'))
545 545 extras = vcs_operation_context(
546 546 environ, repo_name=self.acl_repo_name, username=username,
547 547 action=action, scm=self.SCM, check_locking=check_locking,
548 548 is_shadow_repo=self.is_shadow_repo
549 549 )
550 550
551 551 # ======================================================================
552 552 # REQUEST HANDLING
553 553 # ======================================================================
554 554 repo_path = os.path.join(
555 555 safe_str(self.base_path), safe_str(self.vcs_repo_name))
556 556 log.debug('Repository path is %s', repo_path)
557 557
558 558 fix_PATH()
559 559
560 560 log.info(
561 561 '%s action on %s repo "%s" by "%s" from %s %s',
562 562 action, self.SCM, safe_str(self.url_repo_name),
563 563 safe_str(username), ip_addr, user_agent)
564 564
565 565 return self._generate_vcs_response(
566 566 environ, start_response, repo_path, extras, action)
567 567
568 568 @initialize_generator
569 569 def _generate_vcs_response(
570 570 self, environ, start_response, repo_path, extras, action):
571 571 """
572 572 Returns a generator for the response content.
573 573
574 574 This method is implemented as a generator, so that it can trigger
575 575 the cache validation after all content sent back to the client. It
576 576 also handles the locking exceptions which will be triggered when
577 577 the first chunk is produced by the underlying WSGI application.
578 578 """
579 579 txn_id = ''
580 580 if 'CONTENT_LENGTH' in environ and environ['REQUEST_METHOD'] == 'MERGE':
581 581 # case for SVN, we want to re-use the callback daemon port
582 582 # so we use the txn_id, for this we peek the body, and still save
583 583 # it as wsgi.input
584 584 data = environ['wsgi.input'].read()
585 585 environ['wsgi.input'] = StringIO(data)
586 586 txn_id = extract_svn_txn_id(self.acl_repo_name, data)
587 587
588 588 callback_daemon, extras = self._prepare_callback_daemon(
589 589 extras, environ, action, txn_id=txn_id)
590 590 log.debug('HOOKS extras is %s', extras)
591 591
592 592 config = self._create_config(extras, self.acl_repo_name)
593 593 app = self._create_wsgi_app(repo_path, self.url_repo_name, config)
594 594 with callback_daemon:
595 595 app.rc_extras = extras
596 596
597 597 try:
598 598 response = app(environ, start_response)
599 599 finally:
600 600 # This statement works together with the decorator
601 601 # "initialize_generator" above. The decorator ensures that
602 602 # we hit the first yield statement before the generator is
603 603 # returned back to the WSGI server. This is needed to
604 604 # ensure that the call to "app" above triggers the
605 605 # needed callback to "start_response" before the
606 606 # generator is actually used.
607 607 yield "__init__"
608 608
609 609 # iter content
610 610 for chunk in response:
611 611 yield chunk
612 612
613 613 try:
614 614 # invalidate cache on push
615 615 if action == 'push':
616 616 self._invalidate_cache(self.url_repo_name)
617 617 finally:
618 618 meta.Session.remove()
619 619
620 620 def _get_repository_name(self, environ):
621 621 """Get repository name out of the environmnent
622 622
623 623 :param environ: WSGI environment
624 624 """
625 625 raise NotImplementedError()
626 626
627 627 def _get_action(self, environ):
628 628 """Map request commands into a pull or push command.
629 629
630 630 :param environ: WSGI environment
631 631 """
632 632 raise NotImplementedError()
633 633
634 634 def _create_wsgi_app(self, repo_path, repo_name, config):
635 635 """Return the WSGI app that will finally handle the request."""
636 636 raise NotImplementedError()
637 637
638 638 def _create_config(self, extras, repo_name):
639 639 """Create a safe config representation."""
640 640 raise NotImplementedError()
641 641
642 642 def _should_use_callback_daemon(self, extras, environ, action):
643 643 return True
644 644
645 645 def _prepare_callback_daemon(self, extras, environ, action, txn_id=None):
646 646 direct_calls = vcs_settings.HOOKS_DIRECT_CALLS
647 647 if not self._should_use_callback_daemon(extras, environ, action):
648 648 # disable callback daemon for actions that don't require it
649 649 direct_calls = True
650 650
651 651 return prepare_callback_daemon(
652 652 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
653 653 host=vcs_settings.HOOKS_HOST, use_direct_calls=direct_calls, txn_id=txn_id)
654 654
655 655
656 656 def _should_check_locking(query_string):
657 657 # this is kind of hacky, but due to how mercurial handles client-server
658 658 # server see all operation on commit; bookmarks, phases and
659 659 # obsolescence marker in different transaction, we don't want to check
660 660 # locking on those
661 661 return query_string not in ['cmd=listkeys']
General Comments 0
You need to be logged in to leave comments. Login now