Show More
@@ -1,470 +1,470 b'' | |||
|
1 | 1 | # -*- coding: utf-8 -*- |
|
2 | 2 | |
|
3 | 3 | # Copyright (C) 2016-2020 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 time |
|
22 | 22 | import collections |
|
23 | 23 | import datetime |
|
24 | 24 | import formencode |
|
25 | 25 | import formencode.htmlfill |
|
26 | 26 | import logging |
|
27 | 27 | import urllib.parse |
|
28 | 28 | import requests |
|
29 | 29 | |
|
30 | 30 | from pyramid.httpexceptions import HTTPFound |
|
31 | 31 | |
|
32 | 32 | |
|
33 | 33 | from rhodecode.apps._base import BaseAppView |
|
34 | 34 | from rhodecode.authentication.base import authenticate, HTTP_TYPE |
|
35 | 35 | from rhodecode.authentication.plugins import auth_rhodecode |
|
36 | 36 | from rhodecode.events import UserRegistered, trigger |
|
37 | 37 | from rhodecode.lib import helpers as h |
|
38 | 38 | from rhodecode.lib import audit_logger |
|
39 | 39 | from rhodecode.lib.auth import ( |
|
40 | 40 | AuthUser, HasPermissionAnyDecorator, CSRFRequired) |
|
41 | 41 | from rhodecode.lib.base import get_ip_addr |
|
42 | 42 | from rhodecode.lib.exceptions import UserCreationError |
|
43 | 43 | from rhodecode.lib.utils2 import safe_str |
|
44 | 44 | from rhodecode.model.db import User, UserApiKeys |
|
45 | 45 | from rhodecode.model.forms import LoginForm, RegisterForm, PasswordResetForm |
|
46 | 46 | from rhodecode.model.meta import Session |
|
47 | 47 | from rhodecode.model.auth_token import AuthTokenModel |
|
48 | 48 | from rhodecode.model.settings import SettingsModel |
|
49 | 49 | from rhodecode.model.user import UserModel |
|
50 | 50 | from rhodecode.translation import _ |
|
51 | 51 | |
|
52 | 52 | |
|
53 | 53 | log = logging.getLogger(__name__) |
|
54 | 54 | |
|
55 | 55 | CaptchaData = collections.namedtuple( |
|
56 | 56 | 'CaptchaData', 'active, private_key, public_key') |
|
57 | 57 | |
|
58 | 58 | |
|
59 | 59 | def store_user_in_session(session, username, remember=False): |
|
60 | 60 | user = User.get_by_username(username, case_insensitive=True) |
|
61 | 61 | auth_user = AuthUser(user.user_id) |
|
62 | 62 | auth_user.set_authenticated() |
|
63 | 63 | cs = auth_user.get_cookie_store() |
|
64 | 64 | session['rhodecode_user'] = cs |
|
65 | 65 | user.update_lastlogin() |
|
66 | 66 | Session().commit() |
|
67 | 67 | |
|
68 | 68 | # If they want to be remembered, update the cookie |
|
69 | 69 | if remember: |
|
70 | 70 | _year = (datetime.datetime.now() + |
|
71 | 71 | datetime.timedelta(seconds=60 * 60 * 24 * 365)) |
|
72 | 72 | session._set_cookie_expires(_year) |
|
73 | 73 | |
|
74 | 74 | session.save() |
|
75 | 75 | |
|
76 | 76 | safe_cs = cs.copy() |
|
77 | 77 | safe_cs['password'] = '****' |
|
78 | 78 | log.info('user %s is now authenticated and stored in ' |
|
79 | 79 | 'session, session attrs %s', username, safe_cs) |
|
80 | 80 | |
|
81 | 81 | # dumps session attrs back to cookie |
|
82 | 82 | session._update_cookie_out() |
|
83 | 83 | # we set new cookie |
|
84 | 84 | headers = None |
|
85 | 85 | if session.request['set_cookie']: |
|
86 | 86 | # send set-cookie headers back to response to update cookie |
|
87 | 87 | headers = [('Set-Cookie', session.request['cookie_out'])] |
|
88 | 88 | return headers |
|
89 | 89 | |
|
90 | 90 | |
|
91 | 91 | def get_came_from(request): |
|
92 | 92 | came_from = safe_str(request.GET.get('came_from', '')) |
|
93 |
parsed = urllib.parse.urlparse |
|
|
93 | parsed = urllib.parse.urlparse(came_from) | |
|
94 | 94 | allowed_schemes = ['http', 'https'] |
|
95 | 95 | default_came_from = h.route_path('home') |
|
96 | 96 | if parsed.scheme and parsed.scheme not in allowed_schemes: |
|
97 | 97 | log.error('Suspicious URL scheme detected %s for url %s', |
|
98 | 98 | parsed.scheme, parsed) |
|
99 | 99 | came_from = default_came_from |
|
100 | 100 | elif parsed.netloc and request.host != parsed.netloc: |
|
101 | 101 | log.error('Suspicious NETLOC detected %s for url %s server url ' |
|
102 | 102 | 'is: %s', parsed.netloc, parsed, request.host) |
|
103 | 103 | came_from = default_came_from |
|
104 | 104 | elif any(bad_str in parsed.path for bad_str in ('\r', '\n')): |
|
105 | 105 | log.error('Header injection detected `%s` for url %s server url ', |
|
106 | 106 | parsed.path, parsed) |
|
107 | 107 | came_from = default_came_from |
|
108 | 108 | |
|
109 | 109 | return came_from or default_came_from |
|
110 | 110 | |
|
111 | 111 | |
|
112 | 112 | class LoginView(BaseAppView): |
|
113 | 113 | |
|
114 | 114 | def load_default_context(self): |
|
115 | 115 | c = self._get_local_tmpl_context() |
|
116 | 116 | c.came_from = get_came_from(self.request) |
|
117 | 117 | return c |
|
118 | 118 | |
|
119 | 119 | def _get_captcha_data(self): |
|
120 | 120 | settings = SettingsModel().get_all_settings() |
|
121 | 121 | private_key = settings.get('rhodecode_captcha_private_key') |
|
122 | 122 | public_key = settings.get('rhodecode_captcha_public_key') |
|
123 | 123 | active = bool(private_key) |
|
124 | 124 | return CaptchaData( |
|
125 | 125 | active=active, private_key=private_key, public_key=public_key) |
|
126 | 126 | |
|
127 | 127 | def validate_captcha(self, private_key): |
|
128 | 128 | |
|
129 | 129 | captcha_rs = self.request.POST.get('g-recaptcha-response') |
|
130 | 130 | url = "https://www.google.com/recaptcha/api/siteverify" |
|
131 | 131 | params = { |
|
132 | 132 | 'secret': private_key, |
|
133 | 133 | 'response': captcha_rs, |
|
134 | 134 | 'remoteip': get_ip_addr(self.request.environ) |
|
135 | 135 | } |
|
136 | 136 | verify_rs = requests.get(url, params=params, verify=True, timeout=60) |
|
137 | 137 | verify_rs = verify_rs.json() |
|
138 | 138 | captcha_status = verify_rs.get('success', False) |
|
139 | 139 | captcha_errors = verify_rs.get('error-codes', []) |
|
140 | 140 | if not isinstance(captcha_errors, list): |
|
141 | 141 | captcha_errors = [captcha_errors] |
|
142 | 142 | captcha_errors = ', '.join(captcha_errors) |
|
143 | 143 | captcha_message = '' |
|
144 | 144 | if captcha_status is False: |
|
145 | 145 | captcha_message = "Bad captcha. Errors: {}".format( |
|
146 | 146 | captcha_errors) |
|
147 | 147 | |
|
148 | 148 | return captcha_status, captcha_message |
|
149 | 149 | |
|
150 | 150 | def login(self): |
|
151 | 151 | c = self.load_default_context() |
|
152 | 152 | auth_user = self._rhodecode_user |
|
153 | 153 | |
|
154 | 154 | # redirect if already logged in |
|
155 | 155 | if (auth_user.is_authenticated and |
|
156 | 156 | not auth_user.is_default and auth_user.ip_allowed): |
|
157 | 157 | raise HTTPFound(c.came_from) |
|
158 | 158 | |
|
159 | 159 | # check if we use headers plugin, and try to login using it. |
|
160 | 160 | try: |
|
161 | 161 | log.debug('Running PRE-AUTH for headers based authentication') |
|
162 | 162 | auth_info = authenticate( |
|
163 | 163 | '', '', self.request.environ, HTTP_TYPE, skip_missing=True) |
|
164 | 164 | if auth_info: |
|
165 | 165 | headers = store_user_in_session( |
|
166 | 166 | self.session, auth_info.get('username')) |
|
167 | 167 | raise HTTPFound(c.came_from, headers=headers) |
|
168 | 168 | except UserCreationError as e: |
|
169 | 169 | log.error(e) |
|
170 | 170 | h.flash(e, category='error') |
|
171 | 171 | |
|
172 | 172 | return self._get_template_context(c) |
|
173 | 173 | |
|
174 | 174 | def login_post(self): |
|
175 | 175 | c = self.load_default_context() |
|
176 | 176 | |
|
177 | 177 | login_form = LoginForm(self.request.translate)() |
|
178 | 178 | |
|
179 | 179 | try: |
|
180 | 180 | self.session.invalidate() |
|
181 | 181 | form_result = login_form.to_python(self.request.POST) |
|
182 | 182 | # form checks for username/password, now we're authenticated |
|
183 | 183 | headers = store_user_in_session( |
|
184 | 184 | self.session, |
|
185 | 185 | username=form_result['username'], |
|
186 | 186 | remember=form_result['remember']) |
|
187 | 187 | log.debug('Redirecting to "%s" after login.', c.came_from) |
|
188 | 188 | |
|
189 | 189 | audit_user = audit_logger.UserWrap( |
|
190 | 190 | username=self.request.POST.get('username'), |
|
191 | 191 | ip_addr=self.request.remote_addr) |
|
192 | 192 | action_data = {'user_agent': self.request.user_agent} |
|
193 | 193 | audit_logger.store_web( |
|
194 | 194 | 'user.login.success', action_data=action_data, |
|
195 | 195 | user=audit_user, commit=True) |
|
196 | 196 | |
|
197 | 197 | raise HTTPFound(c.came_from, headers=headers) |
|
198 | 198 | except formencode.Invalid as errors: |
|
199 | 199 | defaults = errors.value |
|
200 | 200 | # remove password from filling in form again |
|
201 | 201 | defaults.pop('password', None) |
|
202 | 202 | render_ctx = { |
|
203 | 203 | 'errors': errors.error_dict, |
|
204 | 204 | 'defaults': defaults, |
|
205 | 205 | } |
|
206 | 206 | |
|
207 | 207 | audit_user = audit_logger.UserWrap( |
|
208 | 208 | username=self.request.POST.get('username'), |
|
209 | 209 | ip_addr=self.request.remote_addr) |
|
210 | 210 | action_data = {'user_agent': self.request.user_agent} |
|
211 | 211 | audit_logger.store_web( |
|
212 | 212 | 'user.login.failure', action_data=action_data, |
|
213 | 213 | user=audit_user, commit=True) |
|
214 | 214 | return self._get_template_context(c, **render_ctx) |
|
215 | 215 | |
|
216 | 216 | except UserCreationError as e: |
|
217 | 217 | # headers auth or other auth functions that create users on |
|
218 | 218 | # the fly can throw this exception signaling that there's issue |
|
219 | 219 | # with user creation, explanation should be provided in |
|
220 | 220 | # Exception itself |
|
221 | 221 | h.flash(e, category='error') |
|
222 | 222 | return self._get_template_context(c) |
|
223 | 223 | |
|
224 | 224 | @CSRFRequired() |
|
225 | 225 | def logout(self): |
|
226 | 226 | auth_user = self._rhodecode_user |
|
227 | 227 | log.info('Deleting session for user: `%s`', auth_user) |
|
228 | 228 | |
|
229 | 229 | action_data = {'user_agent': self.request.user_agent} |
|
230 | 230 | audit_logger.store_web( |
|
231 | 231 | 'user.logout', action_data=action_data, |
|
232 | 232 | user=auth_user, commit=True) |
|
233 | 233 | self.session.delete() |
|
234 | 234 | return HTTPFound(h.route_path('home')) |
|
235 | 235 | |
|
236 | 236 | @HasPermissionAnyDecorator( |
|
237 | 237 | 'hg.admin', 'hg.register.auto_activate', 'hg.register.manual_activate') |
|
238 | 238 | def register(self, defaults=None, errors=None): |
|
239 | 239 | c = self.load_default_context() |
|
240 | 240 | defaults = defaults or {} |
|
241 | 241 | errors = errors or {} |
|
242 | 242 | |
|
243 | 243 | settings = SettingsModel().get_all_settings() |
|
244 | 244 | register_message = settings.get('rhodecode_register_message') or '' |
|
245 | 245 | captcha = self._get_captcha_data() |
|
246 | 246 | auto_active = 'hg.register.auto_activate' in User.get_default_user()\ |
|
247 | 247 | .AuthUser().permissions['global'] |
|
248 | 248 | |
|
249 | 249 | render_ctx = self._get_template_context(c) |
|
250 | 250 | render_ctx.update({ |
|
251 | 251 | 'defaults': defaults, |
|
252 | 252 | 'errors': errors, |
|
253 | 253 | 'auto_active': auto_active, |
|
254 | 254 | 'captcha_active': captcha.active, |
|
255 | 255 | 'captcha_public_key': captcha.public_key, |
|
256 | 256 | 'register_message': register_message, |
|
257 | 257 | }) |
|
258 | 258 | return render_ctx |
|
259 | 259 | |
|
260 | 260 | @HasPermissionAnyDecorator( |
|
261 | 261 | 'hg.admin', 'hg.register.auto_activate', 'hg.register.manual_activate') |
|
262 | 262 | def register_post(self): |
|
263 | 263 | from rhodecode.authentication.plugins import auth_rhodecode |
|
264 | 264 | |
|
265 | 265 | self.load_default_context() |
|
266 | 266 | captcha = self._get_captcha_data() |
|
267 | 267 | auto_active = 'hg.register.auto_activate' in User.get_default_user()\ |
|
268 | 268 | .AuthUser().permissions['global'] |
|
269 | 269 | |
|
270 | 270 | extern_name = auth_rhodecode.RhodeCodeAuthPlugin.uid |
|
271 | 271 | extern_type = auth_rhodecode.RhodeCodeAuthPlugin.uid |
|
272 | 272 | |
|
273 | 273 | register_form = RegisterForm(self.request.translate)() |
|
274 | 274 | try: |
|
275 | 275 | |
|
276 | 276 | form_result = register_form.to_python(self.request.POST) |
|
277 | 277 | form_result['active'] = auto_active |
|
278 | 278 | external_identity = self.request.POST.get('external_identity') |
|
279 | 279 | |
|
280 | 280 | if external_identity: |
|
281 | 281 | extern_name = external_identity |
|
282 | 282 | extern_type = external_identity |
|
283 | 283 | |
|
284 | 284 | if captcha.active: |
|
285 | 285 | captcha_status, captcha_message = self.validate_captcha( |
|
286 | 286 | captcha.private_key) |
|
287 | 287 | |
|
288 | 288 | if not captcha_status: |
|
289 | 289 | _value = form_result |
|
290 | 290 | _msg = _('Bad captcha') |
|
291 | 291 | error_dict = {'recaptcha_field': captcha_message} |
|
292 | 292 | raise formencode.Invalid( |
|
293 | 293 | _msg, _value, None, error_dict=error_dict) |
|
294 | 294 | |
|
295 | 295 | new_user = UserModel().create_registration( |
|
296 | 296 | form_result, extern_name=extern_name, extern_type=extern_type) |
|
297 | 297 | |
|
298 | 298 | action_data = {'data': new_user.get_api_data(), |
|
299 | 299 | 'user_agent': self.request.user_agent} |
|
300 | 300 | |
|
301 | 301 | if external_identity: |
|
302 | 302 | action_data['external_identity'] = external_identity |
|
303 | 303 | |
|
304 | 304 | audit_user = audit_logger.UserWrap( |
|
305 | 305 | username=new_user.username, |
|
306 | 306 | user_id=new_user.user_id, |
|
307 | 307 | ip_addr=self.request.remote_addr) |
|
308 | 308 | |
|
309 | 309 | audit_logger.store_web( |
|
310 | 310 | 'user.register', action_data=action_data, |
|
311 | 311 | user=audit_user) |
|
312 | 312 | |
|
313 | 313 | event = UserRegistered(user=new_user, session=self.session) |
|
314 | 314 | trigger(event) |
|
315 | 315 | h.flash( |
|
316 | 316 | _('You have successfully registered with RhodeCode. You can log-in now.'), |
|
317 | 317 | category='success') |
|
318 | 318 | if external_identity: |
|
319 | 319 | h.flash( |
|
320 | 320 | _('Please use the {identity} button to log-in').format( |
|
321 | 321 | identity=external_identity), |
|
322 | 322 | category='success') |
|
323 | 323 | Session().commit() |
|
324 | 324 | |
|
325 | 325 | redirect_ro = self.request.route_path('login') |
|
326 | 326 | raise HTTPFound(redirect_ro) |
|
327 | 327 | |
|
328 | 328 | except formencode.Invalid as errors: |
|
329 | 329 | errors.value.pop('password', None) |
|
330 | 330 | errors.value.pop('password_confirmation', None) |
|
331 | 331 | return self.register( |
|
332 | 332 | defaults=errors.value, errors=errors.error_dict) |
|
333 | 333 | |
|
334 | 334 | except UserCreationError as e: |
|
335 | 335 | # container auth or other auth functions that create users on |
|
336 | 336 | # the fly can throw this exception signaling that there's issue |
|
337 | 337 | # with user creation, explanation should be provided in |
|
338 | 338 | # Exception itself |
|
339 | 339 | h.flash(e, category='error') |
|
340 | 340 | return self.register() |
|
341 | 341 | |
|
342 | 342 | def password_reset(self): |
|
343 | 343 | c = self.load_default_context() |
|
344 | 344 | captcha = self._get_captcha_data() |
|
345 | 345 | |
|
346 | 346 | template_context = { |
|
347 | 347 | 'captcha_active': captcha.active, |
|
348 | 348 | 'captcha_public_key': captcha.public_key, |
|
349 | 349 | 'defaults': {}, |
|
350 | 350 | 'errors': {}, |
|
351 | 351 | } |
|
352 | 352 | |
|
353 | 353 | # always send implicit message to prevent from discovery of |
|
354 | 354 | # matching emails |
|
355 | 355 | msg = _('If such email exists, a password reset link was sent to it.') |
|
356 | 356 | |
|
357 | 357 | def default_response(): |
|
358 | 358 | log.debug('faking response on invalid password reset') |
|
359 | 359 | # make this take 2s, to prevent brute forcing. |
|
360 | 360 | time.sleep(2) |
|
361 | 361 | h.flash(msg, category='success') |
|
362 | 362 | return HTTPFound(self.request.route_path('reset_password')) |
|
363 | 363 | |
|
364 | 364 | if self.request.POST: |
|
365 | 365 | if h.HasPermissionAny('hg.password_reset.disabled')(): |
|
366 | 366 | _email = self.request.POST.get('email', '') |
|
367 | 367 | log.error('Failed attempt to reset password for `%s`.', _email) |
|
368 | 368 | h.flash(_('Password reset has been disabled.'), category='error') |
|
369 | 369 | return HTTPFound(self.request.route_path('reset_password')) |
|
370 | 370 | |
|
371 | 371 | password_reset_form = PasswordResetForm(self.request.translate)() |
|
372 | 372 | description = u'Generated token for password reset from {}'.format( |
|
373 | 373 | datetime.datetime.now().isoformat()) |
|
374 | 374 | |
|
375 | 375 | try: |
|
376 | 376 | form_result = password_reset_form.to_python( |
|
377 | 377 | self.request.POST) |
|
378 | 378 | user_email = form_result['email'] |
|
379 | 379 | |
|
380 | 380 | if captcha.active: |
|
381 | 381 | captcha_status, captcha_message = self.validate_captcha( |
|
382 | 382 | captcha.private_key) |
|
383 | 383 | |
|
384 | 384 | if not captcha_status: |
|
385 | 385 | _value = form_result |
|
386 | 386 | _msg = _('Bad captcha') |
|
387 | 387 | error_dict = {'recaptcha_field': captcha_message} |
|
388 | 388 | raise formencode.Invalid( |
|
389 | 389 | _msg, _value, None, error_dict=error_dict) |
|
390 | 390 | |
|
391 | 391 | # Generate reset URL and send mail. |
|
392 | 392 | user = User.get_by_email(user_email) |
|
393 | 393 | |
|
394 | 394 | # only allow rhodecode based users to reset their password |
|
395 | 395 | # external auth shouldn't allow password reset |
|
396 | 396 | if user and user.extern_type != auth_rhodecode.RhodeCodeAuthPlugin.uid: |
|
397 | 397 | log.warning('User %s with external type `%s` tried a password reset. ' |
|
398 | 398 | 'This try was rejected', user, user.extern_type) |
|
399 | 399 | return default_response() |
|
400 | 400 | |
|
401 | 401 | # generate password reset token that expires in 10 minutes |
|
402 | 402 | reset_token = UserModel().add_auth_token( |
|
403 | 403 | user=user, lifetime_minutes=10, |
|
404 | 404 | role=UserModel.auth_token_role.ROLE_PASSWORD_RESET, |
|
405 | 405 | description=description) |
|
406 | 406 | Session().commit() |
|
407 | 407 | |
|
408 | 408 | log.debug('Successfully created password recovery token') |
|
409 | 409 | password_reset_url = self.request.route_url( |
|
410 | 410 | 'reset_password_confirmation', |
|
411 | 411 | _query={'key': reset_token.api_key}) |
|
412 | 412 | UserModel().reset_password_link( |
|
413 | 413 | form_result, password_reset_url) |
|
414 | 414 | |
|
415 | 415 | action_data = {'email': user_email, |
|
416 | 416 | 'user_agent': self.request.user_agent} |
|
417 | 417 | audit_logger.store_web( |
|
418 | 418 | 'user.password.reset_request', action_data=action_data, |
|
419 | 419 | user=self._rhodecode_user, commit=True) |
|
420 | 420 | |
|
421 | 421 | return default_response() |
|
422 | 422 | |
|
423 | 423 | except formencode.Invalid as errors: |
|
424 | 424 | template_context.update({ |
|
425 | 425 | 'defaults': errors.value, |
|
426 | 426 | 'errors': errors.error_dict, |
|
427 | 427 | }) |
|
428 | 428 | if not self.request.POST.get('email'): |
|
429 | 429 | # case of empty email, we want to report that |
|
430 | 430 | return self._get_template_context(c, **template_context) |
|
431 | 431 | |
|
432 | 432 | if 'recaptcha_field' in errors.error_dict: |
|
433 | 433 | # case of failed captcha |
|
434 | 434 | return self._get_template_context(c, **template_context) |
|
435 | 435 | |
|
436 | 436 | return default_response() |
|
437 | 437 | |
|
438 | 438 | return self._get_template_context(c, **template_context) |
|
439 | 439 | |
|
440 | 440 | def password_reset_confirmation(self): |
|
441 | 441 | self.load_default_context() |
|
442 | 442 | if self.request.GET and self.request.GET.get('key'): |
|
443 | 443 | # make this take 2s, to prevent brute forcing. |
|
444 | 444 | time.sleep(2) |
|
445 | 445 | |
|
446 | 446 | token = AuthTokenModel().get_auth_token( |
|
447 | 447 | self.request.GET.get('key')) |
|
448 | 448 | |
|
449 | 449 | # verify token is the correct role |
|
450 | 450 | if token is None or token.role != UserApiKeys.ROLE_PASSWORD_RESET: |
|
451 | 451 | log.debug('Got token with role:%s expected is %s', |
|
452 | 452 | getattr(token, 'role', 'EMPTY_TOKEN'), |
|
453 | 453 | UserApiKeys.ROLE_PASSWORD_RESET) |
|
454 | 454 | h.flash( |
|
455 | 455 | _('Given reset token is invalid'), category='error') |
|
456 | 456 | return HTTPFound(self.request.route_path('reset_password')) |
|
457 | 457 | |
|
458 | 458 | try: |
|
459 | 459 | owner = token.user |
|
460 | 460 | data = {'email': owner.email, 'token': token.api_key} |
|
461 | 461 | UserModel().reset_password(data) |
|
462 | 462 | h.flash( |
|
463 | 463 | _('Your password reset was successful, ' |
|
464 | 464 | 'a new password has been sent to your email'), |
|
465 | 465 | category='success') |
|
466 | 466 | except Exception as e: |
|
467 | 467 | log.error(e) |
|
468 | 468 | return HTTPFound(self.request.route_path('reset_password')) |
|
469 | 469 | |
|
470 | 470 | return HTTPFound(self.request.route_path('login')) |
@@ -1,258 +1,258 b'' | |||
|
1 | 1 | # -*- coding: utf-8 -*- |
|
2 | 2 | |
|
3 | 3 | # Copyright (C) 2016-2020 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 re |
|
23 | 23 | import sys |
|
24 | 24 | import logging |
|
25 | 25 | import signal |
|
26 | 26 | import tempfile |
|
27 | 27 | from subprocess import Popen, PIPE |
|
28 | 28 | import urllib.parse |
|
29 | 29 | |
|
30 | 30 | from .base import VcsServer |
|
31 | 31 | |
|
32 | 32 | log = logging.getLogger(__name__) |
|
33 | 33 | |
|
34 | 34 | |
|
35 | 35 | class SubversionTunnelWrapper(object): |
|
36 | 36 | process = None |
|
37 | 37 | |
|
38 | 38 | def __init__(self, server): |
|
39 | 39 | self.server = server |
|
40 | 40 | self.timeout = 30 |
|
41 | 41 | self.stdin = sys.stdin |
|
42 | 42 | self.stdout = sys.stdout |
|
43 | 43 | self.svn_conf_fd, self.svn_conf_path = tempfile.mkstemp() |
|
44 | 44 | self.hooks_env_fd, self.hooks_env_path = tempfile.mkstemp() |
|
45 | 45 | |
|
46 | 46 | self.read_only = True # flag that we set to make the hooks readonly |
|
47 | 47 | |
|
48 | 48 | def create_svn_config(self): |
|
49 | 49 | content = ( |
|
50 | 50 | '[general]\n' |
|
51 | 51 | 'hooks-env = {}\n').format(self.hooks_env_path) |
|
52 | 52 | with os.fdopen(self.svn_conf_fd, 'w') as config_file: |
|
53 | 53 | config_file.write(content) |
|
54 | 54 | |
|
55 | 55 | def create_hooks_env(self): |
|
56 | 56 | content = ( |
|
57 | 57 | '[default]\n' |
|
58 | 58 | 'LANG = en_US.UTF-8\n') |
|
59 | 59 | if self.read_only: |
|
60 | 60 | content += 'SSH_READ_ONLY = 1\n' |
|
61 | 61 | with os.fdopen(self.hooks_env_fd, 'w') as hooks_env_file: |
|
62 | 62 | hooks_env_file.write(content) |
|
63 | 63 | |
|
64 | 64 | def remove_configs(self): |
|
65 | 65 | os.remove(self.svn_conf_path) |
|
66 | 66 | os.remove(self.hooks_env_path) |
|
67 | 67 | |
|
68 | 68 | def command(self): |
|
69 | 69 | root = self.server.get_root_store() |
|
70 | 70 | username = self.server.user.username |
|
71 | 71 | |
|
72 | 72 | command = [ |
|
73 | 73 | self.server.svn_path, '-t', |
|
74 | 74 | '--config-file', self.svn_conf_path, |
|
75 | 75 | '--tunnel-user', username, |
|
76 | 76 | '-r', root] |
|
77 | 77 | log.debug("Final CMD: %s", ' '.join(command)) |
|
78 | 78 | return command |
|
79 | 79 | |
|
80 | 80 | def start(self): |
|
81 | 81 | command = self.command() |
|
82 | 82 | self.process = Popen(' '.join(command), stdin=PIPE, shell=True) |
|
83 | 83 | |
|
84 | 84 | def sync(self): |
|
85 | 85 | while self.process.poll() is None: |
|
86 | 86 | next_byte = self.stdin.read(1) |
|
87 | 87 | if not next_byte: |
|
88 | 88 | break |
|
89 | 89 | self.process.stdin.write(next_byte) |
|
90 | 90 | self.remove_configs() |
|
91 | 91 | |
|
92 | 92 | @property |
|
93 | 93 | def return_code(self): |
|
94 | 94 | return self.process.returncode |
|
95 | 95 | |
|
96 | 96 | def get_first_client_response(self): |
|
97 | 97 | signal.signal(signal.SIGALRM, self.interrupt) |
|
98 | 98 | signal.alarm(self.timeout) |
|
99 | 99 | first_response = self._read_first_client_response() |
|
100 | 100 | signal.alarm(0) |
|
101 | 101 | return (self._parse_first_client_response(first_response) |
|
102 | 102 | if first_response else None) |
|
103 | 103 | |
|
104 | 104 | def patch_first_client_response(self, response, **kwargs): |
|
105 | 105 | self.create_hooks_env() |
|
106 | 106 | data = response.copy() |
|
107 | 107 | data.update(kwargs) |
|
108 | 108 | data['url'] = self._svn_string(data['url']) |
|
109 | 109 | data['ra_client'] = self._svn_string(data['ra_client']) |
|
110 | 110 | data['client'] = data['client'] or '' |
|
111 | 111 | buffer_ = ( |
|
112 | 112 | "( {version} ( {capabilities} ) {url}{ra_client}" |
|
113 | 113 | "( {client}) ) ".format(**data)) |
|
114 | 114 | self.process.stdin.write(buffer_) |
|
115 | 115 | |
|
116 | 116 | def fail(self, message): |
|
117 | 117 | print("( failure ( ( 210005 {message} 0: 0 ) ) )".format( |
|
118 | 118 | message=self._svn_string(message))) |
|
119 | 119 | self.remove_configs() |
|
120 | 120 | self.process.kill() |
|
121 | 121 | return 1 |
|
122 | 122 | |
|
123 | 123 | def interrupt(self, signum, frame): |
|
124 | 124 | self.fail("Exited by timeout") |
|
125 | 125 | |
|
126 | 126 | def _svn_string(self, str_): |
|
127 | 127 | if not str_: |
|
128 | 128 | return '' |
|
129 | 129 | return '{length}:{string} '.format(length=len(str_), string=str_) |
|
130 | 130 | |
|
131 | 131 | def _read_first_client_response(self): |
|
132 | 132 | buffer_ = "" |
|
133 | 133 | brackets_stack = [] |
|
134 | 134 | while True: |
|
135 | 135 | next_byte = self.stdin.read(1) |
|
136 | 136 | buffer_ += next_byte |
|
137 | 137 | if next_byte == "(": |
|
138 | 138 | brackets_stack.append(next_byte) |
|
139 | 139 | elif next_byte == ")": |
|
140 | 140 | brackets_stack.pop() |
|
141 | 141 | elif next_byte == " " and not brackets_stack: |
|
142 | 142 | break |
|
143 | 143 | |
|
144 | 144 | return buffer_ |
|
145 | 145 | |
|
146 | 146 | def _parse_first_client_response(self, buffer_): |
|
147 | 147 | """ |
|
148 | 148 | According to the Subversion RA protocol, the first request |
|
149 | 149 | should look like: |
|
150 | 150 | |
|
151 | 151 | ( version:number ( cap:word ... ) url:string ? ra-client:string |
|
152 | 152 | ( ? client:string ) ) |
|
153 | 153 | |
|
154 | 154 | Please check https://svn.apache.org/repos/asf/subversion/trunk/subversion/libsvn_ra_svn/protocol |
|
155 | 155 | """ |
|
156 | 156 | version_re = r'(?P<version>\d+)' |
|
157 | 157 | capabilities_re = r'\(\s(?P<capabilities>[\w\d\-\ ]+)\s\)' |
|
158 | 158 | url_re = r'\d+\:(?P<url>[\W\w]+)' |
|
159 | 159 | ra_client_re = r'(\d+\:(?P<ra_client>[\W\w]+)\s)' |
|
160 | 160 | client_re = r'(\d+\:(?P<client>[\W\w]+)\s)*' |
|
161 | 161 | regex = re.compile( |
|
162 | 162 | r'^\(\s{version}\s{capabilities}\s{url}\s{ra_client}' |
|
163 | 163 | r'\(\s{client}\)\s\)\s*$'.format( |
|
164 | 164 | version=version_re, capabilities=capabilities_re, |
|
165 | 165 | url=url_re, ra_client=ra_client_re, client=client_re)) |
|
166 | 166 | matcher = regex.match(buffer_) |
|
167 | 167 | |
|
168 | 168 | return matcher.groupdict() if matcher else None |
|
169 | 169 | |
|
170 | 170 | def _match_repo_name(self, url): |
|
171 | 171 | """ |
|
172 | 172 | Given an server url, try to match it against ALL known repository names. |
|
173 | 173 | This handles a tricky SVN case for SSH and subdir commits. |
|
174 | 174 | E.g if our repo name is my-svn-repo, a svn commit on file in a subdir would |
|
175 | 175 | result in the url with this subdir added. |
|
176 | 176 | """ |
|
177 | 177 | # case 1 direct match, we don't do any "heavy" lookups |
|
178 | 178 | if url in self.server.user_permissions: |
|
179 | 179 | return url |
|
180 | 180 | |
|
181 | 181 | log.debug('Extracting repository name from subdir path %s', url) |
|
182 | 182 | # case 2 we check all permissions, and match closes possible case... |
|
183 | 183 | # NOTE(dan): In this case we only know that url has a subdir parts, it's safe |
|
184 | 184 | # to assume that it will have the repo name as prefix, we ensure the prefix |
|
185 | 185 | # for similar repositories isn't matched by adding a / |
|
186 | 186 | # e.g subgroup/repo-name/ and subgroup/repo-name-1/ would work correct. |
|
187 | 187 | for repo_name in self.server.user_permissions: |
|
188 | 188 | repo_name_prefix = repo_name + '/' |
|
189 | 189 | if url.startswith(repo_name_prefix): |
|
190 | 190 | log.debug('Found prefix %s match, returning proper repository name', |
|
191 | 191 | repo_name_prefix) |
|
192 | 192 | return repo_name |
|
193 | 193 | |
|
194 | 194 | return |
|
195 | 195 | |
|
196 | 196 | def run(self, extras): |
|
197 | 197 | action = 'pull' |
|
198 | 198 | self.create_svn_config() |
|
199 | 199 | self.start() |
|
200 | 200 | |
|
201 | 201 | first_response = self.get_first_client_response() |
|
202 | 202 | if not first_response: |
|
203 | 203 | return self.fail("Repository name cannot be extracted") |
|
204 | 204 | |
|
205 |
url_parts = urllib.parse.urlparse |
|
|
205 | url_parts = urllib.parse.urlparse(first_response['url']) | |
|
206 | 206 | |
|
207 | 207 | self.server.repo_name = self._match_repo_name(url_parts.path.strip('/')) |
|
208 | 208 | |
|
209 | 209 | exit_code = self.server._check_permissions(action) |
|
210 | 210 | if exit_code: |
|
211 | 211 | return exit_code |
|
212 | 212 | |
|
213 | 213 | # set the readonly flag to False if we have proper permissions |
|
214 | 214 | if self.server.has_write_perm(): |
|
215 | 215 | self.read_only = False |
|
216 | 216 | self.server.update_environment(action=action, extras=extras) |
|
217 | 217 | |
|
218 | 218 | self.patch_first_client_response(first_response) |
|
219 | 219 | self.sync() |
|
220 | 220 | return self.return_code |
|
221 | 221 | |
|
222 | 222 | |
|
223 | 223 | class SubversionServer(VcsServer): |
|
224 | 224 | backend = 'svn' |
|
225 | 225 | repo_user_agent = 'svn' |
|
226 | 226 | |
|
227 | 227 | def __init__(self, store, ini_path, repo_name, |
|
228 | 228 | user, user_permissions, config, env): |
|
229 | 229 | super(SubversionServer, self)\ |
|
230 | 230 | .__init__(user, user_permissions, config, env) |
|
231 | 231 | self.store = store |
|
232 | 232 | self.ini_path = ini_path |
|
233 | 233 | # NOTE(dan): repo_name at this point is empty, |
|
234 | 234 | # this is set later in .run() based from parsed input stream |
|
235 | 235 | self.repo_name = repo_name |
|
236 | 236 | self._path = self.svn_path = config.get('app:main', 'ssh.executable.svn') |
|
237 | 237 | |
|
238 | 238 | self.tunnel = SubversionTunnelWrapper(server=self) |
|
239 | 239 | |
|
240 | 240 | def _handle_tunnel(self, extras): |
|
241 | 241 | |
|
242 | 242 | # pre-auth |
|
243 | 243 | action = 'pull' |
|
244 | 244 | # Special case for SVN, we extract repo name at later stage |
|
245 | 245 | # exit_code = self._check_permissions(action) |
|
246 | 246 | # if exit_code: |
|
247 | 247 | # return exit_code, False |
|
248 | 248 | |
|
249 | 249 | req = self.env['request'] |
|
250 | 250 | server_url = req.host_url + req.script_name |
|
251 | 251 | extras['server_url'] = server_url |
|
252 | 252 | |
|
253 | 253 | log.debug('Using %s binaries from path %s', self.backend, self._path) |
|
254 | 254 | exit_code = self.tunnel.run(extras) |
|
255 | 255 | |
|
256 | 256 | return exit_code, action == "push" |
|
257 | 257 | |
|
258 | 258 |
@@ -1,580 +1,580 b'' | |||
|
1 | 1 | # -*- coding: utf-8 -*- |
|
2 | 2 | |
|
3 | 3 | # Copyright (C) 2011-2020 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 | """ |
|
23 | 23 | Renderer for markup languages with ability to parse using rst or markdown |
|
24 | 24 | """ |
|
25 | 25 | |
|
26 | 26 | import re |
|
27 | 27 | import os |
|
28 | 28 | import lxml |
|
29 | 29 | import logging |
|
30 | 30 | import urllib.parse |
|
31 | 31 | import bleach |
|
32 | 32 | |
|
33 | 33 | from mako.lookup import TemplateLookup |
|
34 | 34 | from mako.template import Template as MakoTemplate |
|
35 | 35 | |
|
36 | 36 | from docutils.core import publish_parts |
|
37 | 37 | from docutils.parsers.rst import directives |
|
38 | 38 | from docutils import writers |
|
39 | 39 | from docutils.writers import html4css1 |
|
40 | 40 | import markdown |
|
41 | 41 | |
|
42 | 42 | from rhodecode.lib.markdown_ext import GithubFlavoredMarkdownExtension |
|
43 | 43 | from rhodecode.lib.utils2 import (safe_unicode, md5_safe, MENTIONS_REGEX) |
|
44 | 44 | |
|
45 | 45 | log = logging.getLogger(__name__) |
|
46 | 46 | |
|
47 | 47 | # default renderer used to generate automated comments |
|
48 | 48 | DEFAULT_COMMENTS_RENDERER = 'rst' |
|
49 | 49 | |
|
50 | 50 | try: |
|
51 | 51 | from lxml.html import fromstring |
|
52 | 52 | from lxml.html import tostring |
|
53 | 53 | except ImportError: |
|
54 | 54 | log.exception('Failed to import lxml') |
|
55 | 55 | fromstring = None |
|
56 | 56 | tostring = None |
|
57 | 57 | |
|
58 | 58 | |
|
59 | 59 | class CustomHTMLTranslator(writers.html4css1.HTMLTranslator): |
|
60 | 60 | """ |
|
61 | 61 | Custom HTML Translator used for sandboxing potential |
|
62 | 62 | JS injections in ref links |
|
63 | 63 | """ |
|
64 | 64 | def visit_literal_block(self, node): |
|
65 | 65 | self.body.append(self.starttag(node, 'pre', CLASS='codehilite literal-block')) |
|
66 | 66 | |
|
67 | 67 | def visit_reference(self, node): |
|
68 | 68 | if 'refuri' in node.attributes: |
|
69 | 69 | refuri = node['refuri'] |
|
70 | 70 | if ':' in refuri: |
|
71 | 71 | prefix, link = refuri.lstrip().split(':', 1) |
|
72 | 72 | prefix = prefix or '' |
|
73 | 73 | |
|
74 | 74 | if prefix.lower() == 'javascript': |
|
75 | 75 | # we don't allow javascript type of refs... |
|
76 | 76 | node['refuri'] = 'javascript:alert("SandBoxedJavascript")' |
|
77 | 77 | |
|
78 | 78 | # old style class requires this... |
|
79 | 79 | return html4css1.HTMLTranslator.visit_reference(self, node) |
|
80 | 80 | |
|
81 | 81 | |
|
82 | 82 | class RhodeCodeWriter(writers.html4css1.Writer): |
|
83 | 83 | def __init__(self): |
|
84 | 84 | writers.Writer.__init__(self) |
|
85 | 85 | self.translator_class = CustomHTMLTranslator |
|
86 | 86 | |
|
87 | 87 | |
|
88 | 88 | def relative_links(html_source, server_paths): |
|
89 | 89 | if not html_source: |
|
90 | 90 | return html_source |
|
91 | 91 | |
|
92 | 92 | if not fromstring and tostring: |
|
93 | 93 | return html_source |
|
94 | 94 | |
|
95 | 95 | try: |
|
96 | 96 | doc = lxml.html.fromstring(html_source) |
|
97 | 97 | except Exception: |
|
98 | 98 | return html_source |
|
99 | 99 | |
|
100 | 100 | for el in doc.cssselect('img, video'): |
|
101 | 101 | src = el.attrib.get('src') |
|
102 | 102 | if src: |
|
103 | 103 | el.attrib['src'] = relative_path(src, server_paths['raw']) |
|
104 | 104 | |
|
105 | 105 | for el in doc.cssselect('a:not(.gfm)'): |
|
106 | 106 | src = el.attrib.get('href') |
|
107 | 107 | if src: |
|
108 | 108 | raw_mode = el.attrib['href'].endswith('?raw=1') |
|
109 | 109 | if raw_mode: |
|
110 | 110 | el.attrib['href'] = relative_path(src, server_paths['raw']) |
|
111 | 111 | else: |
|
112 | 112 | el.attrib['href'] = relative_path(src, server_paths['standard']) |
|
113 | 113 | |
|
114 | 114 | return lxml.html.tostring(doc) |
|
115 | 115 | |
|
116 | 116 | |
|
117 | 117 | def relative_path(path, request_path, is_repo_file=None): |
|
118 | 118 | """ |
|
119 | 119 | relative link support, path is a rel path, and request_path is current |
|
120 | 120 | server path (not absolute) |
|
121 | 121 | |
|
122 | 122 | e.g. |
|
123 | 123 | |
|
124 | 124 | path = '../logo.png' |
|
125 | 125 | request_path= '/repo/files/path/file.md' |
|
126 | 126 | produces: '/repo/files/logo.png' |
|
127 | 127 | """ |
|
128 | 128 | # TODO(marcink): unicode/str support ? |
|
129 | 129 | # maybe=> safe_unicode(urllib.quote(safe_str(final_path), '/:')) |
|
130 | 130 | |
|
131 | 131 | def dummy_check(p): |
|
132 | 132 | return True # assume default is a valid file path |
|
133 | 133 | |
|
134 | 134 | is_repo_file = is_repo_file or dummy_check |
|
135 | 135 | if not path: |
|
136 | 136 | return request_path |
|
137 | 137 | |
|
138 | 138 | path = safe_unicode(path) |
|
139 | 139 | request_path = safe_unicode(request_path) |
|
140 | 140 | |
|
141 | 141 | if path.startswith((u'data:', u'javascript:', u'#', u':')): |
|
142 | 142 | # skip data, anchor, invalid links |
|
143 | 143 | return path |
|
144 | 144 | |
|
145 |
is_absolute = bool(urllib.parse.urlparse |
|
|
145 | is_absolute = bool(urllib.parse.urlparse(path).netloc) | |
|
146 | 146 | if is_absolute: |
|
147 | 147 | return path |
|
148 | 148 | |
|
149 | 149 | if not request_path: |
|
150 | 150 | return path |
|
151 | 151 | |
|
152 | 152 | if path.startswith(u'/'): |
|
153 | 153 | path = path[1:] |
|
154 | 154 | |
|
155 | 155 | if path.startswith(u'./'): |
|
156 | 156 | path = path[2:] |
|
157 | 157 | |
|
158 | 158 | parts = request_path.split('/') |
|
159 | 159 | # compute how deep we need to traverse the request_path |
|
160 | 160 | depth = 0 |
|
161 | 161 | |
|
162 | 162 | if is_repo_file(request_path): |
|
163 | 163 | # if request path is a VALID file, we use a relative path with |
|
164 | 164 | # one level up |
|
165 | 165 | depth += 1 |
|
166 | 166 | |
|
167 | 167 | while path.startswith(u'../'): |
|
168 | 168 | depth += 1 |
|
169 | 169 | path = path[3:] |
|
170 | 170 | |
|
171 | 171 | if depth > 0: |
|
172 | 172 | parts = parts[:-depth] |
|
173 | 173 | |
|
174 | 174 | parts.append(path) |
|
175 | 175 | final_path = u'/'.join(parts).lstrip(u'/') |
|
176 | 176 | |
|
177 | 177 | return u'/' + final_path |
|
178 | 178 | |
|
179 | 179 | |
|
180 | 180 | _cached_markdown_renderer = None |
|
181 | 181 | |
|
182 | 182 | |
|
183 | 183 | def get_markdown_renderer(extensions, output_format): |
|
184 | 184 | global _cached_markdown_renderer |
|
185 | 185 | |
|
186 | 186 | if _cached_markdown_renderer is None: |
|
187 | 187 | _cached_markdown_renderer = markdown.Markdown( |
|
188 | 188 | extensions=extensions, |
|
189 | 189 | enable_attributes=False, output_format=output_format) |
|
190 | 190 | return _cached_markdown_renderer |
|
191 | 191 | |
|
192 | 192 | |
|
193 | 193 | _cached_markdown_renderer_flavored = None |
|
194 | 194 | |
|
195 | 195 | |
|
196 | 196 | def get_markdown_renderer_flavored(extensions, output_format): |
|
197 | 197 | global _cached_markdown_renderer_flavored |
|
198 | 198 | |
|
199 | 199 | if _cached_markdown_renderer_flavored is None: |
|
200 | 200 | _cached_markdown_renderer_flavored = markdown.Markdown( |
|
201 | 201 | extensions=extensions + [GithubFlavoredMarkdownExtension()], |
|
202 | 202 | enable_attributes=False, output_format=output_format) |
|
203 | 203 | return _cached_markdown_renderer_flavored |
|
204 | 204 | |
|
205 | 205 | |
|
206 | 206 | class MarkupRenderer(object): |
|
207 | 207 | RESTRUCTUREDTEXT_DISALLOWED_DIRECTIVES = ['include', 'meta', 'raw'] |
|
208 | 208 | |
|
209 | 209 | MARKDOWN_PAT = re.compile(r'\.(md|mkdn?|mdown|markdown)$', re.IGNORECASE) |
|
210 | 210 | RST_PAT = re.compile(r'\.re?st$', re.IGNORECASE) |
|
211 | 211 | JUPYTER_PAT = re.compile(r'\.(ipynb)$', re.IGNORECASE) |
|
212 | 212 | PLAIN_PAT = re.compile(r'^readme$', re.IGNORECASE) |
|
213 | 213 | |
|
214 | 214 | URL_PAT = re.compile(r'(http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]' |
|
215 | 215 | r'|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+)') |
|
216 | 216 | |
|
217 | 217 | MENTION_PAT = re.compile(MENTIONS_REGEX) |
|
218 | 218 | |
|
219 | 219 | extensions = ['markdown.extensions.codehilite', 'markdown.extensions.extra', |
|
220 | 220 | 'markdown.extensions.def_list', 'markdown.extensions.sane_lists'] |
|
221 | 221 | |
|
222 | 222 | output_format = 'html4' |
|
223 | 223 | |
|
224 | 224 | # extension together with weights. Lower is first means we control how |
|
225 | 225 | # extensions are attached to readme names with those. |
|
226 | 226 | PLAIN_EXTS = [ |
|
227 | 227 | # prefer no extension |
|
228 | 228 | ('', 0), # special case that renders READMES names without extension |
|
229 | 229 | ('.text', 2), ('.TEXT', 2), |
|
230 | 230 | ('.txt', 3), ('.TXT', 3) |
|
231 | 231 | ] |
|
232 | 232 | |
|
233 | 233 | RST_EXTS = [ |
|
234 | 234 | ('.rst', 1), ('.rest', 1), |
|
235 | 235 | ('.RST', 2), ('.REST', 2) |
|
236 | 236 | ] |
|
237 | 237 | |
|
238 | 238 | MARKDOWN_EXTS = [ |
|
239 | 239 | ('.md', 1), ('.MD', 1), |
|
240 | 240 | ('.mkdn', 2), ('.MKDN', 2), |
|
241 | 241 | ('.mdown', 3), ('.MDOWN', 3), |
|
242 | 242 | ('.markdown', 4), ('.MARKDOWN', 4) |
|
243 | 243 | ] |
|
244 | 244 | |
|
245 | 245 | def _detect_renderer(self, source, filename=None): |
|
246 | 246 | """ |
|
247 | 247 | runs detection of what renderer should be used for generating html |
|
248 | 248 | from a markup language |
|
249 | 249 | |
|
250 | 250 | filename can be also explicitly a renderer name |
|
251 | 251 | |
|
252 | 252 | :param source: |
|
253 | 253 | :param filename: |
|
254 | 254 | """ |
|
255 | 255 | |
|
256 | 256 | if MarkupRenderer.MARKDOWN_PAT.findall(filename): |
|
257 | 257 | detected_renderer = 'markdown' |
|
258 | 258 | elif MarkupRenderer.RST_PAT.findall(filename): |
|
259 | 259 | detected_renderer = 'rst' |
|
260 | 260 | elif MarkupRenderer.JUPYTER_PAT.findall(filename): |
|
261 | 261 | detected_renderer = 'jupyter' |
|
262 | 262 | elif MarkupRenderer.PLAIN_PAT.findall(filename): |
|
263 | 263 | detected_renderer = 'plain' |
|
264 | 264 | else: |
|
265 | 265 | detected_renderer = 'plain' |
|
266 | 266 | |
|
267 | 267 | return getattr(MarkupRenderer, detected_renderer) |
|
268 | 268 | |
|
269 | 269 | @classmethod |
|
270 | 270 | def bleach_clean(cls, text): |
|
271 | 271 | from .bleach_whitelist import markdown_attrs, markdown_tags |
|
272 | 272 | allowed_tags = markdown_tags |
|
273 | 273 | allowed_attrs = markdown_attrs |
|
274 | 274 | |
|
275 | 275 | try: |
|
276 | 276 | return bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs) |
|
277 | 277 | except Exception: |
|
278 | 278 | return 'UNPARSEABLE TEXT' |
|
279 | 279 | |
|
280 | 280 | @classmethod |
|
281 | 281 | def renderer_from_filename(cls, filename, exclude): |
|
282 | 282 | """ |
|
283 | 283 | Detect renderer markdown/rst from filename and optionally use exclude |
|
284 | 284 | list to remove some options. This is mostly used in helpers. |
|
285 | 285 | Returns None when no renderer can be detected. |
|
286 | 286 | """ |
|
287 | 287 | def _filter(elements): |
|
288 | 288 | if isinstance(exclude, (list, tuple)): |
|
289 | 289 | return [x for x in elements if x not in exclude] |
|
290 | 290 | return elements |
|
291 | 291 | |
|
292 | 292 | if filename.endswith( |
|
293 | 293 | tuple(_filter([x[0] for x in cls.MARKDOWN_EXTS if x[0]]))): |
|
294 | 294 | return 'markdown' |
|
295 | 295 | if filename.endswith(tuple(_filter([x[0] for x in cls.RST_EXTS if x[0]]))): |
|
296 | 296 | return 'rst' |
|
297 | 297 | |
|
298 | 298 | return None |
|
299 | 299 | |
|
300 | 300 | def render(self, source, filename=None): |
|
301 | 301 | """ |
|
302 | 302 | Renders a given filename using detected renderer |
|
303 | 303 | it detects renderers based on file extension or mimetype. |
|
304 | 304 | At last it will just do a simple html replacing new lines with <br/> |
|
305 | 305 | |
|
306 | 306 | :param file_name: |
|
307 | 307 | :param source: |
|
308 | 308 | """ |
|
309 | 309 | |
|
310 | 310 | renderer = self._detect_renderer(source, filename) |
|
311 | 311 | readme_data = renderer(source) |
|
312 | 312 | return readme_data |
|
313 | 313 | |
|
314 | 314 | @classmethod |
|
315 | 315 | def _flavored_markdown(cls, text): |
|
316 | 316 | """ |
|
317 | 317 | Github style flavored markdown |
|
318 | 318 | |
|
319 | 319 | :param text: |
|
320 | 320 | """ |
|
321 | 321 | |
|
322 | 322 | # Extract pre blocks. |
|
323 | 323 | extractions = {} |
|
324 | 324 | |
|
325 | 325 | def pre_extraction_callback(matchobj): |
|
326 | 326 | digest = md5_safe(matchobj.group(0)) |
|
327 | 327 | extractions[digest] = matchobj.group(0) |
|
328 | 328 | return "{gfm-extraction-%s}" % digest |
|
329 | 329 | pattern = re.compile(r'<pre>.*?</pre>', re.MULTILINE | re.DOTALL) |
|
330 | 330 | text = re.sub(pattern, pre_extraction_callback, text) |
|
331 | 331 | |
|
332 | 332 | # Prevent foo_bar_baz from ending up with an italic word in the middle. |
|
333 | 333 | def italic_callback(matchobj): |
|
334 | 334 | s = matchobj.group(0) |
|
335 | 335 | if list(s).count('_') >= 2: |
|
336 | 336 | return s.replace('_', r'\_') |
|
337 | 337 | return s |
|
338 | 338 | text = re.sub(r'^(?! {4}|\t)\w+_\w+_\w[\w_]*', italic_callback, text) |
|
339 | 339 | |
|
340 | 340 | # Insert pre block extractions. |
|
341 | 341 | def pre_insert_callback(matchobj): |
|
342 | 342 | return '\n\n' + extractions[matchobj.group(1)] |
|
343 | 343 | text = re.sub(r'\{gfm-extraction-([0-9a-f]{32})\}', |
|
344 | 344 | pre_insert_callback, text) |
|
345 | 345 | |
|
346 | 346 | return text |
|
347 | 347 | |
|
348 | 348 | @classmethod |
|
349 | 349 | def urlify_text(cls, text): |
|
350 | 350 | def url_func(match_obj): |
|
351 | 351 | url_full = match_obj.groups()[0] |
|
352 | 352 | return '<a href="%(url)s">%(url)s</a>' % ({'url': url_full}) |
|
353 | 353 | |
|
354 | 354 | return cls.URL_PAT.sub(url_func, text) |
|
355 | 355 | |
|
356 | 356 | @classmethod |
|
357 | 357 | def convert_mentions(cls, text, mode): |
|
358 | 358 | mention_pat = cls.MENTION_PAT |
|
359 | 359 | |
|
360 | 360 | def wrapp(match_obj): |
|
361 | 361 | uname = match_obj.groups()[0] |
|
362 | 362 | hovercard_url = "pyroutes.url('hovercard_username', {'username': '%s'});" % uname |
|
363 | 363 | |
|
364 | 364 | if mode == 'markdown': |
|
365 | 365 | tmpl = '<strong class="tooltip-hovercard" data-hovercard-alt="{uname}" data-hovercard-url="{hovercard_url}">@{uname}</strong>' |
|
366 | 366 | elif mode == 'rst': |
|
367 | 367 | tmpl = ' **@{uname}** ' |
|
368 | 368 | else: |
|
369 | 369 | raise ValueError('mode must be rst or markdown') |
|
370 | 370 | |
|
371 | 371 | return tmpl.format(**{'uname': uname, |
|
372 | 372 | 'hovercard_url': hovercard_url}) |
|
373 | 373 | |
|
374 | 374 | return mention_pat.sub(wrapp, text).strip() |
|
375 | 375 | |
|
376 | 376 | @classmethod |
|
377 | 377 | def plain(cls, source, universal_newline=True, leading_newline=True): |
|
378 | 378 | source = safe_unicode(source) |
|
379 | 379 | if universal_newline: |
|
380 | 380 | newline = '\n' |
|
381 | 381 | source = newline.join(source.splitlines()) |
|
382 | 382 | |
|
383 | 383 | rendered_source = cls.urlify_text(source) |
|
384 | 384 | source = '' |
|
385 | 385 | if leading_newline: |
|
386 | 386 | source += '<br />' |
|
387 | 387 | source += rendered_source.replace("\n", '<br />') |
|
388 | 388 | |
|
389 | 389 | rendered = cls.bleach_clean(source) |
|
390 | 390 | return rendered |
|
391 | 391 | |
|
392 | 392 | @classmethod |
|
393 | 393 | def markdown(cls, source, safe=True, flavored=True, mentions=False, |
|
394 | 394 | clean_html=True): |
|
395 | 395 | """ |
|
396 | 396 | returns markdown rendered code cleaned by the bleach library |
|
397 | 397 | """ |
|
398 | 398 | |
|
399 | 399 | if flavored: |
|
400 | 400 | markdown_renderer = get_markdown_renderer_flavored( |
|
401 | 401 | cls.extensions, cls.output_format) |
|
402 | 402 | else: |
|
403 | 403 | markdown_renderer = get_markdown_renderer( |
|
404 | 404 | cls.extensions, cls.output_format) |
|
405 | 405 | |
|
406 | 406 | if mentions: |
|
407 | 407 | mention_hl = cls.convert_mentions(source, mode='markdown') |
|
408 | 408 | # we extracted mentions render with this using Mentions false |
|
409 | 409 | return cls.markdown(mention_hl, safe=safe, flavored=flavored, |
|
410 | 410 | mentions=False) |
|
411 | 411 | |
|
412 | 412 | source = safe_unicode(source) |
|
413 | 413 | |
|
414 | 414 | try: |
|
415 | 415 | if flavored: |
|
416 | 416 | source = cls._flavored_markdown(source) |
|
417 | 417 | rendered = markdown_renderer.convert(source) |
|
418 | 418 | except Exception: |
|
419 | 419 | log.exception('Error when rendering Markdown') |
|
420 | 420 | if safe: |
|
421 | 421 | log.debug('Fallback to render in plain mode') |
|
422 | 422 | rendered = cls.plain(source) |
|
423 | 423 | else: |
|
424 | 424 | raise |
|
425 | 425 | |
|
426 | 426 | if clean_html: |
|
427 | 427 | rendered = cls.bleach_clean(rendered) |
|
428 | 428 | return rendered |
|
429 | 429 | |
|
430 | 430 | @classmethod |
|
431 | 431 | def rst(cls, source, safe=True, mentions=False, clean_html=False): |
|
432 | 432 | if mentions: |
|
433 | 433 | mention_hl = cls.convert_mentions(source, mode='rst') |
|
434 | 434 | # we extracted mentions render with this using Mentions false |
|
435 | 435 | return cls.rst(mention_hl, safe=safe, mentions=False) |
|
436 | 436 | |
|
437 | 437 | source = safe_unicode(source) |
|
438 | 438 | try: |
|
439 | 439 | docutils_settings = dict( |
|
440 | 440 | [(alias, None) for alias in |
|
441 | 441 | cls.RESTRUCTUREDTEXT_DISALLOWED_DIRECTIVES]) |
|
442 | 442 | |
|
443 | 443 | docutils_settings.update({ |
|
444 | 444 | 'input_encoding': 'unicode', |
|
445 | 445 | 'report_level': 4, |
|
446 | 446 | 'syntax_highlight': 'short', |
|
447 | 447 | }) |
|
448 | 448 | |
|
449 | 449 | for k, v in docutils_settings.items(): |
|
450 | 450 | directives.register_directive(k, v) |
|
451 | 451 | |
|
452 | 452 | parts = publish_parts(source=source, |
|
453 | 453 | writer=RhodeCodeWriter(), |
|
454 | 454 | settings_overrides=docutils_settings) |
|
455 | 455 | rendered = parts["fragment"] |
|
456 | 456 | if clean_html: |
|
457 | 457 | rendered = cls.bleach_clean(rendered) |
|
458 | 458 | return parts['html_title'] + rendered |
|
459 | 459 | except Exception: |
|
460 | 460 | log.exception('Error when rendering RST') |
|
461 | 461 | if safe: |
|
462 | 462 | log.debug('Fallback to render in plain mode') |
|
463 | 463 | return cls.plain(source) |
|
464 | 464 | else: |
|
465 | 465 | raise |
|
466 | 466 | |
|
467 | 467 | @classmethod |
|
468 | 468 | def jupyter(cls, source, safe=True): |
|
469 | 469 | from rhodecode.lib import helpers |
|
470 | 470 | |
|
471 | 471 | from traitlets.config import Config |
|
472 | 472 | import nbformat |
|
473 | 473 | from nbconvert import HTMLExporter |
|
474 | 474 | from nbconvert.preprocessors import Preprocessor |
|
475 | 475 | |
|
476 | 476 | class CustomHTMLExporter(HTMLExporter): |
|
477 | 477 | def _template_file_default(self): |
|
478 | 478 | return 'basic' |
|
479 | 479 | |
|
480 | 480 | class Sandbox(Preprocessor): |
|
481 | 481 | |
|
482 | 482 | def preprocess(self, nb, resources): |
|
483 | 483 | sandbox_text = 'SandBoxed(IPython.core.display.Javascript object)' |
|
484 | 484 | for cell in nb['cells']: |
|
485 | 485 | if not safe: |
|
486 | 486 | continue |
|
487 | 487 | |
|
488 | 488 | if 'outputs' in cell: |
|
489 | 489 | for cell_output in cell['outputs']: |
|
490 | 490 | if 'data' in cell_output: |
|
491 | 491 | if 'application/javascript' in cell_output['data']: |
|
492 | 492 | cell_output['data']['text/plain'] = sandbox_text |
|
493 | 493 | cell_output['data'].pop('application/javascript', None) |
|
494 | 494 | |
|
495 | 495 | if 'source' in cell and cell['cell_type'] == 'markdown': |
|
496 | 496 | # sanitize similar like in markdown |
|
497 | 497 | cell['source'] = cls.bleach_clean(cell['source']) |
|
498 | 498 | |
|
499 | 499 | return nb, resources |
|
500 | 500 | |
|
501 | 501 | def _sanitize_resources(input_resources): |
|
502 | 502 | """ |
|
503 | 503 | Skip/sanitize some of the CSS generated and included in jupyter |
|
504 | 504 | so it doesn't messes up UI so much |
|
505 | 505 | """ |
|
506 | 506 | |
|
507 | 507 | # TODO(marcink): probably we should replace this with whole custom |
|
508 | 508 | # CSS set that doesn't screw up, but jupyter generated html has some |
|
509 | 509 | # special markers, so it requires Custom HTML exporter template with |
|
510 | 510 | # _default_template_path_default, to achieve that |
|
511 | 511 | |
|
512 | 512 | # strip the reset CSS |
|
513 | 513 | input_resources[0] = input_resources[0][input_resources[0].find('/*! Source'):] |
|
514 | 514 | return input_resources |
|
515 | 515 | |
|
516 | 516 | def as_html(notebook): |
|
517 | 517 | conf = Config() |
|
518 | 518 | conf.CustomHTMLExporter.preprocessors = [Sandbox] |
|
519 | 519 | html_exporter = CustomHTMLExporter(config=conf) |
|
520 | 520 | |
|
521 | 521 | (body, resources) = html_exporter.from_notebook_node(notebook) |
|
522 | 522 | header = '<!-- ## IPYTHON NOTEBOOK RENDERING ## -->' |
|
523 | 523 | js = MakoTemplate(r''' |
|
524 | 524 | <!-- MathJax configuration --> |
|
525 | 525 | <script type="text/x-mathjax-config"> |
|
526 | 526 | MathJax.Hub.Config({ |
|
527 | 527 | jax: ["input/TeX","output/HTML-CSS", "output/PreviewHTML"], |
|
528 | 528 | extensions: ["tex2jax.js","MathMenu.js","MathZoom.js", "fast-preview.js", "AssistiveMML.js", "[Contrib]/a11y/accessibility-menu.js"], |
|
529 | 529 | TeX: { |
|
530 | 530 | extensions: ["AMSmath.js","AMSsymbols.js","noErrors.js","noUndefined.js"] |
|
531 | 531 | }, |
|
532 | 532 | tex2jax: { |
|
533 | 533 | inlineMath: [ ['$','$'], ["\\(","\\)"] ], |
|
534 | 534 | displayMath: [ ['$$','$$'], ["\\[","\\]"] ], |
|
535 | 535 | processEscapes: true, |
|
536 | 536 | processEnvironments: true |
|
537 | 537 | }, |
|
538 | 538 | // Center justify equations in code and markdown cells. Elsewhere |
|
539 | 539 | // we use CSS to left justify single line equations in code cells. |
|
540 | 540 | displayAlign: 'center', |
|
541 | 541 | "HTML-CSS": { |
|
542 | 542 | styles: {'.MathJax_Display': {"margin": 0}}, |
|
543 | 543 | linebreaks: { automatic: true }, |
|
544 | 544 | availableFonts: ["STIX", "TeX"] |
|
545 | 545 | }, |
|
546 | 546 | showMathMenu: false |
|
547 | 547 | }); |
|
548 | 548 | </script> |
|
549 | 549 | <!-- End of MathJax configuration --> |
|
550 | 550 | <script src="${h.asset('js/src/math_jax/MathJax.js')}"></script> |
|
551 | 551 | ''').render(h=helpers) |
|
552 | 552 | |
|
553 | 553 | css = MakoTemplate(r''' |
|
554 | 554 | <link rel="stylesheet" type="text/css" href="${h.asset('css/style-ipython.css', ver=ver)}" media="screen"/> |
|
555 | 555 | ''').render(h=helpers, ver='ver1') |
|
556 | 556 | |
|
557 | 557 | body = '\n'.join([header, css, js, body]) |
|
558 | 558 | return body, resources |
|
559 | 559 | |
|
560 | 560 | notebook = nbformat.reads(source, as_version=4) |
|
561 | 561 | (body, resources) = as_html(notebook) |
|
562 | 562 | return body |
|
563 | 563 | |
|
564 | 564 | |
|
565 | 565 | class RstTemplateRenderer(object): |
|
566 | 566 | |
|
567 | 567 | def __init__(self): |
|
568 | 568 | base = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) |
|
569 | 569 | rst_template_dirs = [os.path.join(base, 'templates', 'rst_templates')] |
|
570 | 570 | self.template_store = TemplateLookup( |
|
571 | 571 | directories=rst_template_dirs, |
|
572 | 572 | input_encoding='utf-8', |
|
573 | 573 | imports=['from rhodecode.lib import helpers as h']) |
|
574 | 574 | |
|
575 | 575 | def _get_template(self, templatename): |
|
576 | 576 | return self.template_store.get_template(templatename) |
|
577 | 577 | |
|
578 | 578 | def render(self, template_name, **kwargs): |
|
579 | 579 | template = self._get_template(template_name) |
|
580 | 580 | return template.render(**kwargs) |
@@ -1,229 +1,229 b'' | |||
|
1 | 1 | # -*- coding: utf-8 -*- |
|
2 | 2 | |
|
3 | 3 | # Copyright (C) 2010-2020 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.request, urllib.parse, urllib.error |
|
24 | 24 | import urllib.parse |
|
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, safe_str |
|
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 | 54 | path_info = self._get_url( |
|
55 | 55 | self.config.get('subversion_http_server_url', ''), environ['PATH_INFO']) |
|
56 | 56 | transfer_encoding = environ.get('HTTP_TRANSFER_ENCODING', '') |
|
57 | 57 | log.debug('Handling: %s method via `%s`', req_method, path_info) |
|
58 | 58 | |
|
59 | 59 | # stream control flag, based on request and content type... |
|
60 | 60 | stream = False |
|
61 | 61 | |
|
62 | 62 | if req_method in ['MKCOL'] or has_content_length: |
|
63 | 63 | data_processed = False |
|
64 | 64 | # read chunk to check if we have txn-with-props |
|
65 | 65 | initial_data = data.read(1024) |
|
66 | 66 | if initial_data.startswith('(create-txn-with-props'): |
|
67 | 67 | data = initial_data + data.read() |
|
68 | 68 | # store on-the-fly our rc_extra using svn revision properties |
|
69 | 69 | # those can be read later on in hooks executed so we have a way |
|
70 | 70 | # to pass in the data into svn hooks |
|
71 | 71 | rc_data = base64.urlsafe_b64encode(json.dumps(self.rc_extras)) |
|
72 | 72 | rc_data_len = len(rc_data) |
|
73 | 73 | # header defines data length, and serialized data |
|
74 | 74 | skel = ' rc-scm-extras {} {}'.format(rc_data_len, rc_data) |
|
75 | 75 | data = data[:-2] + skel + '))' |
|
76 | 76 | data_processed = True |
|
77 | 77 | |
|
78 | 78 | if not data_processed: |
|
79 | 79 | # NOTE(johbo): Avoid that we end up with sending the request in chunked |
|
80 | 80 | # transfer encoding (mainly on Gunicorn). If we know the content |
|
81 | 81 | # length, then we should transfer the payload in one request. |
|
82 | 82 | data = initial_data + data.read() |
|
83 | 83 | |
|
84 | 84 | if req_method in ['GET', 'PUT'] or transfer_encoding == 'chunked': |
|
85 | 85 | # NOTE(marcink): when getting/uploading files we want to STREAM content |
|
86 | 86 | # back to the client/proxy instead of buffering it here... |
|
87 | 87 | stream = True |
|
88 | 88 | |
|
89 | 89 | stream = stream |
|
90 | 90 | log.debug('Calling SVN PROXY at `%s`, using method:%s. Stream: %s', |
|
91 | 91 | path_info, req_method, stream) |
|
92 | 92 | try: |
|
93 | 93 | response = requests.request( |
|
94 | 94 | req_method, path_info, |
|
95 | 95 | data=data, headers=request_headers, stream=stream) |
|
96 | 96 | except requests.ConnectionError: |
|
97 | 97 | log.exception('ConnectionError occurred for endpoint %s', path_info) |
|
98 | 98 | raise |
|
99 | 99 | |
|
100 | 100 | if response.status_code not in [200, 401]: |
|
101 | 101 | from rhodecode.lib.utils2 import safe_str |
|
102 | 102 | text = '\n{}'.format(safe_str(response.text)) if response.text else '' |
|
103 | 103 | if response.status_code >= 500: |
|
104 | 104 | log.error('Got SVN response:%s with text:`%s`', response, text) |
|
105 | 105 | else: |
|
106 | 106 | log.debug('Got SVN response:%s with text:`%s`', response, text) |
|
107 | 107 | else: |
|
108 | 108 | log.debug('got response code: %s', response.status_code) |
|
109 | 109 | |
|
110 | 110 | response_headers = self._get_response_headers(response.headers) |
|
111 | 111 | |
|
112 | 112 | if response.headers.get('SVN-Txn-name'): |
|
113 | 113 | svn_tx_id = response.headers.get('SVN-Txn-name') |
|
114 | 114 | txn_id = rc_cache.utils.compute_key_from_params( |
|
115 | 115 | self.config['repository'], svn_tx_id) |
|
116 | 116 | port = safe_int(self.rc_extras['hooks_uri'].split(':')[-1]) |
|
117 | 117 | store_txn_id_data(txn_id, {'port': port}) |
|
118 | 118 | |
|
119 | 119 | start_response( |
|
120 | 120 | '{} {}'.format(response.status_code, response.reason), |
|
121 | 121 | response_headers) |
|
122 | 122 | return response.iter_content(chunk_size=1024) |
|
123 | 123 | |
|
124 | 124 | def _get_url(self, svn_http_server, path): |
|
125 | 125 | svn_http_server_url = (svn_http_server or '').rstrip('/') |
|
126 |
url_path = urllib.parse |
|
|
126 | url_path = urllib.parse.urljoin(svn_http_server_url + '/', (path or '').lstrip('/')) | |
|
127 | 127 | url_path = urllib.parse.quote(url_path, safe="/:=~+!$,;'") |
|
128 | 128 | return url_path |
|
129 | 129 | |
|
130 | 130 | def _get_request_headers(self, environ): |
|
131 | 131 | headers = {} |
|
132 | 132 | |
|
133 | 133 | for key in environ: |
|
134 | 134 | if not key.startswith('HTTP_'): |
|
135 | 135 | continue |
|
136 | 136 | new_key = key.split('_') |
|
137 | 137 | new_key = [k.capitalize() for k in new_key[1:]] |
|
138 | 138 | new_key = '-'.join(new_key) |
|
139 | 139 | headers[new_key] = environ[key] |
|
140 | 140 | |
|
141 | 141 | if 'CONTENT_TYPE' in environ: |
|
142 | 142 | headers['Content-Type'] = environ['CONTENT_TYPE'] |
|
143 | 143 | |
|
144 | 144 | if 'CONTENT_LENGTH' in environ: |
|
145 | 145 | headers['Content-Length'] = environ['CONTENT_LENGTH'] |
|
146 | 146 | |
|
147 | 147 | return headers |
|
148 | 148 | |
|
149 | 149 | def _get_response_headers(self, headers): |
|
150 | 150 | headers = [ |
|
151 | 151 | (h, headers[h]) |
|
152 | 152 | for h in headers |
|
153 | 153 | if h.lower() not in self.IGNORED_HEADERS |
|
154 | 154 | ] |
|
155 | 155 | |
|
156 | 156 | return headers |
|
157 | 157 | |
|
158 | 158 | |
|
159 | 159 | class DisabledSimpleSvnApp(object): |
|
160 | 160 | def __init__(self, config): |
|
161 | 161 | self.config = config |
|
162 | 162 | |
|
163 | 163 | def __call__(self, environ, start_response): |
|
164 | 164 | reason = 'Cannot handle SVN call because: SVN HTTP Proxy is not enabled' |
|
165 | 165 | log.warning(reason) |
|
166 | 166 | return HTTPNotAcceptable(reason)(environ, start_response) |
|
167 | 167 | |
|
168 | 168 | |
|
169 | 169 | class SimpleSvn(simplevcs.SimpleVCS): |
|
170 | 170 | |
|
171 | 171 | SCM = 'svn' |
|
172 | 172 | READ_ONLY_COMMANDS = ('OPTIONS', 'PROPFIND', 'GET', 'REPORT') |
|
173 | 173 | DEFAULT_HTTP_SERVER = 'http://localhost:8090' |
|
174 | 174 | |
|
175 | 175 | def _get_repository_name(self, environ): |
|
176 | 176 | """ |
|
177 | 177 | Gets repository name out of PATH_INFO header |
|
178 | 178 | |
|
179 | 179 | :param environ: environ where PATH_INFO is stored |
|
180 | 180 | """ |
|
181 | 181 | path = environ['PATH_INFO'].split('!') |
|
182 | 182 | repo_name = path[0].strip('/') |
|
183 | 183 | |
|
184 | 184 | # SVN includes the whole path in it's requests, including |
|
185 | 185 | # subdirectories inside the repo. Therefore we have to search for |
|
186 | 186 | # the repo root directory. |
|
187 | 187 | if not is_valid_repo( |
|
188 | 188 | repo_name, self.base_path, explicit_scm=self.SCM): |
|
189 | 189 | current_path = '' |
|
190 | 190 | for component in repo_name.split('/'): |
|
191 | 191 | current_path += component |
|
192 | 192 | if is_valid_repo( |
|
193 | 193 | current_path, self.base_path, explicit_scm=self.SCM): |
|
194 | 194 | return current_path |
|
195 | 195 | current_path += '/' |
|
196 | 196 | |
|
197 | 197 | return repo_name |
|
198 | 198 | |
|
199 | 199 | def _get_action(self, environ): |
|
200 | 200 | return ( |
|
201 | 201 | 'pull' |
|
202 | 202 | if environ['REQUEST_METHOD'] in self.READ_ONLY_COMMANDS |
|
203 | 203 | else 'push') |
|
204 | 204 | |
|
205 | 205 | def _should_use_callback_daemon(self, extras, environ, action): |
|
206 | 206 | # only MERGE command triggers hooks, so we don't want to start |
|
207 | 207 | # hooks server too many times. POST however starts the svn transaction |
|
208 | 208 | # so we also need to run the init of callback daemon of POST |
|
209 | 209 | if environ['REQUEST_METHOD'] in ['MERGE', 'POST']: |
|
210 | 210 | return True |
|
211 | 211 | return False |
|
212 | 212 | |
|
213 | 213 | def _create_wsgi_app(self, repo_path, repo_name, config): |
|
214 | 214 | if self._is_svn_enabled(): |
|
215 | 215 | return SimpleSvnApp(config) |
|
216 | 216 | # we don't have http proxy enabled return dummy request handler |
|
217 | 217 | return DisabledSimpleSvnApp(config) |
|
218 | 218 | |
|
219 | 219 | def _is_svn_enabled(self): |
|
220 | 220 | conf = self.repo_vcs_config |
|
221 | 221 | return str2bool(conf.get('vcs_svn_proxy', 'http_requests_enabled')) |
|
222 | 222 | |
|
223 | 223 | def _create_config(self, extras, repo_name, scheme='http'): |
|
224 | 224 | conf = self.repo_vcs_config |
|
225 | 225 | server_url = conf.get('vcs_svn_proxy', 'http_server_url') |
|
226 | 226 | server_url = server_url or self.DEFAULT_HTTP_SERVER |
|
227 | 227 | |
|
228 | 228 | extras['subversion_http_server_url'] = server_url |
|
229 | 229 | return extras |
@@ -1,189 +1,189 b'' | |||
|
1 | 1 | # -*- coding: utf-8 -*- |
|
2 | 2 | |
|
3 | 3 | # Copyright (C) 2014-2020 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 | Implementation of the scm_app interface using raw HTTP communication. |
|
23 | 23 | """ |
|
24 | 24 | |
|
25 | 25 | import base64 |
|
26 | 26 | import logging |
|
27 | 27 | import urllib.parse |
|
28 | 28 | import wsgiref.util |
|
29 | 29 | |
|
30 | 30 | import msgpack |
|
31 | 31 | import requests |
|
32 | 32 | import webob.request |
|
33 | 33 | |
|
34 | 34 | import rhodecode |
|
35 | 35 | |
|
36 | 36 | |
|
37 | 37 | log = logging.getLogger(__name__) |
|
38 | 38 | |
|
39 | 39 | |
|
40 | 40 | def create_git_wsgi_app(repo_path, repo_name, config): |
|
41 | 41 | url = _vcs_streaming_url() + 'git/' |
|
42 | 42 | return VcsHttpProxy(url, repo_path, repo_name, config) |
|
43 | 43 | |
|
44 | 44 | |
|
45 | 45 | def create_hg_wsgi_app(repo_path, repo_name, config): |
|
46 | 46 | url = _vcs_streaming_url() + 'hg/' |
|
47 | 47 | return VcsHttpProxy(url, repo_path, repo_name, config) |
|
48 | 48 | |
|
49 | 49 | |
|
50 | 50 | def _vcs_streaming_url(): |
|
51 | 51 | template = 'http://{}/stream/' |
|
52 | 52 | return template.format(rhodecode.CONFIG['vcs.server']) |
|
53 | 53 | |
|
54 | 54 | |
|
55 | 55 | # TODO: johbo: Avoid the global. |
|
56 | 56 | session = requests.Session() |
|
57 | 57 | # Requests speedup, avoid reading .netrc and similar |
|
58 | 58 | session.trust_env = False |
|
59 | 59 | |
|
60 | 60 | # prevent urllib3 spawning our logs. |
|
61 | 61 | logging.getLogger("requests.packages.urllib3.connectionpool").setLevel( |
|
62 | 62 | logging.WARNING) |
|
63 | 63 | |
|
64 | 64 | |
|
65 | 65 | class VcsHttpProxy(object): |
|
66 | 66 | """ |
|
67 | 67 | A WSGI application which proxies vcs requests. |
|
68 | 68 | |
|
69 | 69 | The goal is to shuffle the data around without touching it. The only |
|
70 | 70 | exception is the extra data from the config object which we send to the |
|
71 | 71 | server as well. |
|
72 | 72 | """ |
|
73 | 73 | |
|
74 | 74 | def __init__(self, url, repo_path, repo_name, config): |
|
75 | 75 | """ |
|
76 | 76 | :param str url: The URL of the VCSServer to call. |
|
77 | 77 | """ |
|
78 | 78 | self._url = url |
|
79 | 79 | self._repo_name = repo_name |
|
80 | 80 | self._repo_path = repo_path |
|
81 | 81 | self._config = config |
|
82 | 82 | self.rc_extras = {} |
|
83 | 83 | log.debug( |
|
84 | 84 | "Creating VcsHttpProxy for repo %s, url %s", |
|
85 | 85 | repo_name, url) |
|
86 | 86 | |
|
87 | 87 | def __call__(self, environ, start_response): |
|
88 | 88 | config = msgpack.packb(self._config) |
|
89 | 89 | request = webob.request.Request(environ) |
|
90 | 90 | request_headers = request.headers |
|
91 | 91 | |
|
92 | 92 | request_headers.update({ |
|
93 | 93 | # TODO: johbo: Remove this, rely on URL path only |
|
94 | 94 | 'X-RC-Repo-Name': self._repo_name, |
|
95 | 95 | 'X-RC-Repo-Path': self._repo_path, |
|
96 | 96 | 'X-RC-Path-Info': environ['PATH_INFO'], |
|
97 | 97 | |
|
98 | 98 | 'X-RC-Repo-Store': self.rc_extras.get('repo_store'), |
|
99 | 99 | 'X-RC-Server-Config-File': self.rc_extras.get('config'), |
|
100 | 100 | |
|
101 | 101 | 'X-RC-Auth-User': self.rc_extras.get('username'), |
|
102 | 102 | 'X-RC-Auth-User-Id': str(self.rc_extras.get('user_id')), |
|
103 | 103 | 'X-RC-Auth-User-Ip': self.rc_extras.get('ip'), |
|
104 | 104 | |
|
105 | 105 | # TODO: johbo: Avoid encoding and put this into payload? |
|
106 | 106 | 'X-RC-Repo-Config': base64.b64encode(config), |
|
107 | 107 | 'X-RC-Locked-Status-Code': rhodecode.CONFIG.get('lock_ret_code'), |
|
108 | 108 | }) |
|
109 | 109 | |
|
110 | 110 | method = environ['REQUEST_METHOD'] |
|
111 | 111 | |
|
112 | 112 | # Preserve the query string |
|
113 | 113 | url = self._url |
|
114 |
url = urllib.parse |
|
|
114 | url = urllib.parse.urljoin(url, self._repo_name) | |
|
115 | 115 | if environ.get('QUERY_STRING'): |
|
116 | 116 | url += '?' + environ['QUERY_STRING'] |
|
117 | 117 | |
|
118 | 118 | log.debug('http-app: preparing request to: %s', url) |
|
119 | 119 | response = session.request( |
|
120 | 120 | method, |
|
121 | 121 | url, |
|
122 | 122 | data=_maybe_stream_request(environ), |
|
123 | 123 | headers=request_headers, |
|
124 | 124 | stream=True) |
|
125 | 125 | |
|
126 | 126 | log.debug('http-app: got vcsserver response: %s', response) |
|
127 | 127 | if response.status_code >= 500: |
|
128 | 128 | log.error('Exception returned by vcsserver at: %s %s, %s', |
|
129 | 129 | url, response.status_code, response.content) |
|
130 | 130 | |
|
131 | 131 | # Preserve the headers of the response, except hop_by_hop ones |
|
132 | 132 | response_headers = [ |
|
133 | 133 | (h, v) for h, v in response.headers.items() |
|
134 | 134 | if not wsgiref.util.is_hop_by_hop(h) |
|
135 | 135 | ] |
|
136 | 136 | |
|
137 | 137 | # Build status argument for start_response callable. |
|
138 | 138 | status = '{status_code} {reason_phrase}'.format( |
|
139 | 139 | status_code=response.status_code, |
|
140 | 140 | reason_phrase=response.reason) |
|
141 | 141 | |
|
142 | 142 | start_response(status, response_headers) |
|
143 | 143 | return _maybe_stream_response(response) |
|
144 | 144 | |
|
145 | 145 | |
|
146 | 146 | def read_in_chunks(stream_obj, block_size=1024, chunks=-1): |
|
147 | 147 | """ |
|
148 | 148 | Read Stream in chunks, default chunk size: 1k. |
|
149 | 149 | """ |
|
150 | 150 | while chunks: |
|
151 | 151 | data = stream_obj.read(block_size) |
|
152 | 152 | if not data: |
|
153 | 153 | break |
|
154 | 154 | yield data |
|
155 | 155 | chunks -= 1 |
|
156 | 156 | |
|
157 | 157 | |
|
158 | 158 | def _is_request_chunked(environ): |
|
159 | 159 | stream = environ.get('HTTP_TRANSFER_ENCODING', '') == 'chunked' |
|
160 | 160 | return stream |
|
161 | 161 | |
|
162 | 162 | |
|
163 | 163 | def _maybe_stream_request(environ): |
|
164 | 164 | path = environ['PATH_INFO'] |
|
165 | 165 | stream = _is_request_chunked(environ) |
|
166 | 166 | log.debug('handling request `%s` with stream support: %s', path, stream) |
|
167 | 167 | |
|
168 | 168 | if stream: |
|
169 | 169 | # set stream by 256k |
|
170 | 170 | return read_in_chunks(environ['wsgi.input'], block_size=1024 * 256) |
|
171 | 171 | else: |
|
172 | 172 | return environ['wsgi.input'].read() |
|
173 | 173 | |
|
174 | 174 | |
|
175 | 175 | def _maybe_stream_response(response): |
|
176 | 176 | """ |
|
177 | 177 | Try to generate chunks from the response if it is chunked. |
|
178 | 178 | """ |
|
179 | 179 | stream = _is_chunked(response) |
|
180 | 180 | log.debug('returning response with stream: %s', stream) |
|
181 | 181 | if stream: |
|
182 | 182 | # read in 256k Chunks |
|
183 | 183 | return response.raw.read_chunked(amt=1024 * 256) |
|
184 | 184 | else: |
|
185 | 185 | return [response.content] |
|
186 | 186 | |
|
187 | 187 | |
|
188 | 188 | def _is_chunked(response): |
|
189 | 189 | return response.headers.get('Transfer-Encoding', '') == 'chunked' |
@@ -1,412 +1,412 b'' | |||
|
1 | 1 | # -*- coding: utf-8 -*- |
|
2 | 2 | |
|
3 | 3 | # Copyright (C) 2016-2020 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 | Client for the VCSServer implemented based on HTTP. |
|
23 | 23 | """ |
|
24 | 24 | |
|
25 | 25 | import copy |
|
26 | 26 | import logging |
|
27 | 27 | import threading |
|
28 | 28 | import time |
|
29 | 29 | import urllib.request, urllib.error, urllib.parse |
|
30 | 30 | import urllib.parse |
|
31 | 31 | import uuid |
|
32 | 32 | import traceback |
|
33 | 33 | |
|
34 | 34 | import pycurl |
|
35 | 35 | import msgpack |
|
36 | 36 | import requests |
|
37 | 37 | from requests.packages.urllib3.util.retry import Retry |
|
38 | 38 | |
|
39 | 39 | import rhodecode |
|
40 | 40 | from rhodecode.lib import rc_cache |
|
41 | 41 | from rhodecode.lib.rc_cache.utils import compute_key_from_params |
|
42 | 42 | from rhodecode.lib.system_info import get_cert_path |
|
43 | 43 | from rhodecode.lib.vcs import exceptions, CurlSession |
|
44 | 44 | from rhodecode.lib.utils2 import str2bool |
|
45 | 45 | |
|
46 | 46 | log = logging.getLogger(__name__) |
|
47 | 47 | |
|
48 | 48 | |
|
49 | 49 | # TODO: mikhail: Keep it in sync with vcsserver's |
|
50 | 50 | # HTTPApplication.ALLOWED_EXCEPTIONS |
|
51 | 51 | EXCEPTIONS_MAP = { |
|
52 | 52 | 'KeyError': KeyError, |
|
53 | 53 | 'URLError': urllib.error.URLError, |
|
54 | 54 | } |
|
55 | 55 | |
|
56 | 56 | |
|
57 | 57 | def _remote_call(url, payload, exceptions_map, session): |
|
58 | 58 | try: |
|
59 | 59 | headers = { |
|
60 | 60 | 'X-RC-Method': payload.get('method'), |
|
61 | 61 | 'X-RC-Repo-Name': payload.get('_repo_name') |
|
62 | 62 | } |
|
63 | 63 | response = session.post(url, data=msgpack.packb(payload), headers=headers) |
|
64 | 64 | except pycurl.error as e: |
|
65 | 65 | msg = '{}. \npycurl traceback: {}'.format(e, traceback.format_exc()) |
|
66 | 66 | raise exceptions.HttpVCSCommunicationError(msg) |
|
67 | 67 | except Exception as e: |
|
68 | 68 | message = getattr(e, 'message', '') |
|
69 | 69 | if 'Failed to connect' in message: |
|
70 | 70 | # gevent doesn't return proper pycurl errors |
|
71 | 71 | raise exceptions.HttpVCSCommunicationError(e) |
|
72 | 72 | else: |
|
73 | 73 | raise |
|
74 | 74 | |
|
75 | 75 | if response.status_code >= 400: |
|
76 | 76 | log.error('Call to %s returned non 200 HTTP code: %s', |
|
77 | 77 | url, response.status_code) |
|
78 | 78 | raise exceptions.HttpVCSCommunicationError(repr(response.content)) |
|
79 | 79 | |
|
80 | 80 | try: |
|
81 | 81 | response = msgpack.unpackb(response.content) |
|
82 | 82 | except Exception: |
|
83 | 83 | log.exception('Failed to decode response %r', response.content) |
|
84 | 84 | raise |
|
85 | 85 | |
|
86 | 86 | error = response.get('error') |
|
87 | 87 | if error: |
|
88 | 88 | type_ = error.get('type', 'Exception') |
|
89 | 89 | exc = exceptions_map.get(type_, Exception) |
|
90 | 90 | exc = exc(error.get('message')) |
|
91 | 91 | try: |
|
92 | 92 | exc._vcs_kind = error['_vcs_kind'] |
|
93 | 93 | except KeyError: |
|
94 | 94 | pass |
|
95 | 95 | |
|
96 | 96 | try: |
|
97 | 97 | exc._vcs_server_traceback = error['traceback'] |
|
98 | 98 | exc._vcs_server_org_exc_name = error['org_exc'] |
|
99 | 99 | exc._vcs_server_org_exc_tb = error['org_exc_tb'] |
|
100 | 100 | except KeyError: |
|
101 | 101 | pass |
|
102 | 102 | |
|
103 | 103 | raise exc |
|
104 | 104 | return response.get('result') |
|
105 | 105 | |
|
106 | 106 | |
|
107 | 107 | def _streaming_remote_call(url, payload, exceptions_map, session, chunk_size): |
|
108 | 108 | try: |
|
109 | 109 | headers = { |
|
110 | 110 | 'X-RC-Method': payload.get('method'), |
|
111 | 111 | 'X-RC-Repo-Name': payload.get('_repo_name') |
|
112 | 112 | } |
|
113 | 113 | response = session.post(url, data=msgpack.packb(payload), headers=headers) |
|
114 | 114 | except pycurl.error as e: |
|
115 | 115 | msg = '{}. \npycurl traceback: {}'.format(e, traceback.format_exc()) |
|
116 | 116 | raise exceptions.HttpVCSCommunicationError(msg) |
|
117 | 117 | except Exception as e: |
|
118 | 118 | message = getattr(e, 'message', '') |
|
119 | 119 | if 'Failed to connect' in message: |
|
120 | 120 | # gevent doesn't return proper pycurl errors |
|
121 | 121 | raise exceptions.HttpVCSCommunicationError(e) |
|
122 | 122 | else: |
|
123 | 123 | raise |
|
124 | 124 | |
|
125 | 125 | if response.status_code >= 400: |
|
126 | 126 | log.error('Call to %s returned non 200 HTTP code: %s', |
|
127 | 127 | url, response.status_code) |
|
128 | 128 | raise exceptions.HttpVCSCommunicationError(repr(response.content)) |
|
129 | 129 | |
|
130 | 130 | return response.iter_content(chunk_size=chunk_size) |
|
131 | 131 | |
|
132 | 132 | |
|
133 | 133 | class ServiceConnection(object): |
|
134 | 134 | def __init__(self, server_and_port, backend_endpoint, session_factory): |
|
135 |
self.url = urllib.parse |
|
|
135 | self.url = urllib.parse.urljoin('http://%s' % server_and_port, backend_endpoint) | |
|
136 | 136 | self._session_factory = session_factory |
|
137 | 137 | |
|
138 | 138 | def __getattr__(self, name): |
|
139 | 139 | def f(*args, **kwargs): |
|
140 | 140 | return self._call(name, *args, **kwargs) |
|
141 | 141 | return f |
|
142 | 142 | |
|
143 | 143 | @exceptions.map_vcs_exceptions |
|
144 | 144 | def _call(self, name, *args, **kwargs): |
|
145 | 145 | payload = { |
|
146 | 146 | 'id': str(uuid.uuid4()), |
|
147 | 147 | 'method': name, |
|
148 | 148 | 'params': {'args': args, 'kwargs': kwargs} |
|
149 | 149 | } |
|
150 | 150 | return _remote_call( |
|
151 | 151 | self.url, payload, EXCEPTIONS_MAP, self._session_factory()) |
|
152 | 152 | |
|
153 | 153 | |
|
154 | 154 | class RemoteVCSMaker(object): |
|
155 | 155 | |
|
156 | 156 | def __init__(self, server_and_port, backend_endpoint, backend_type, session_factory): |
|
157 |
self.url = urllib.parse |
|
|
158 |
self.stream_url = urllib.parse |
|
|
157 | self.url = urllib.parse.urljoin('http://%s' % server_and_port, backend_endpoint) | |
|
158 | self.stream_url = urllib.parse.urljoin('http://%s' % server_and_port, backend_endpoint+'/stream') | |
|
159 | 159 | |
|
160 | 160 | self._session_factory = session_factory |
|
161 | 161 | self.backend_type = backend_type |
|
162 | 162 | |
|
163 | 163 | @classmethod |
|
164 | 164 | def init_cache_region(cls, repo_id): |
|
165 | 165 | cache_namespace_uid = 'cache_repo.{}'.format(repo_id) |
|
166 | 166 | region = rc_cache.get_or_create_region('cache_repo', cache_namespace_uid) |
|
167 | 167 | return region, cache_namespace_uid |
|
168 | 168 | |
|
169 | 169 | def __call__(self, path, repo_id, config, with_wire=None): |
|
170 | 170 | log.debug('%s RepoMaker call on %s', self.backend_type.upper(), path) |
|
171 | 171 | return RemoteRepo(path, repo_id, config, self, with_wire=with_wire) |
|
172 | 172 | |
|
173 | 173 | def __getattr__(self, name): |
|
174 | 174 | def remote_attr(*args, **kwargs): |
|
175 | 175 | return self._call(name, *args, **kwargs) |
|
176 | 176 | return remote_attr |
|
177 | 177 | |
|
178 | 178 | @exceptions.map_vcs_exceptions |
|
179 | 179 | def _call(self, func_name, *args, **kwargs): |
|
180 | 180 | payload = { |
|
181 | 181 | 'id': str(uuid.uuid4()), |
|
182 | 182 | 'method': func_name, |
|
183 | 183 | 'backend': self.backend_type, |
|
184 | 184 | 'params': {'args': args, 'kwargs': kwargs} |
|
185 | 185 | } |
|
186 | 186 | url = self.url |
|
187 | 187 | return _remote_call(url, payload, EXCEPTIONS_MAP, self._session_factory()) |
|
188 | 188 | |
|
189 | 189 | |
|
190 | 190 | class RemoteRepo(object): |
|
191 | 191 | CHUNK_SIZE = 16384 |
|
192 | 192 | |
|
193 | 193 | def __init__(self, path, repo_id, config, remote_maker, with_wire=None): |
|
194 | 194 | self.url = remote_maker.url |
|
195 | 195 | self.stream_url = remote_maker.stream_url |
|
196 | 196 | self._session = remote_maker._session_factory() |
|
197 | 197 | |
|
198 | 198 | cache_repo_id = self._repo_id_sanitizer(repo_id) |
|
199 | 199 | _repo_name = self._get_repo_name(config, path) |
|
200 | 200 | self._cache_region, self._cache_namespace = \ |
|
201 | 201 | remote_maker.init_cache_region(cache_repo_id) |
|
202 | 202 | |
|
203 | 203 | with_wire = with_wire or {} |
|
204 | 204 | |
|
205 | 205 | repo_state_uid = with_wire.get('repo_state_uid') or 'state' |
|
206 | 206 | |
|
207 | 207 | self._wire = { |
|
208 | 208 | "_repo_name": _repo_name, |
|
209 | 209 | "path": path, # repo path |
|
210 | 210 | "repo_id": repo_id, |
|
211 | 211 | "cache_repo_id": cache_repo_id, |
|
212 | 212 | "config": config, |
|
213 | 213 | "repo_state_uid": repo_state_uid, |
|
214 | 214 | "context": self._create_vcs_cache_context(path, repo_state_uid) |
|
215 | 215 | } |
|
216 | 216 | |
|
217 | 217 | if with_wire: |
|
218 | 218 | self._wire.update(with_wire) |
|
219 | 219 | |
|
220 | 220 | # NOTE(johbo): Trading complexity for performance. Avoiding the call to |
|
221 | 221 | # log.debug brings a few percent gain even if is is not active. |
|
222 | 222 | if log.isEnabledFor(logging.DEBUG): |
|
223 | 223 | self._call_with_logging = True |
|
224 | 224 | |
|
225 | 225 | self.cert_dir = get_cert_path(rhodecode.CONFIG.get('__file__')) |
|
226 | 226 | |
|
227 | 227 | def _get_repo_name(self, config, path): |
|
228 | 228 | repo_store = config.get('paths', '/') |
|
229 | 229 | return path.split(repo_store)[-1].lstrip('/') |
|
230 | 230 | |
|
231 | 231 | def _repo_id_sanitizer(self, repo_id): |
|
232 | 232 | pathless = repo_id.replace('/', '__').replace('-', '_') |
|
233 | 233 | return ''.join(char if ord(char) < 128 else '_{}_'.format(ord(char)) for char in pathless) |
|
234 | 234 | |
|
235 | 235 | def __getattr__(self, name): |
|
236 | 236 | |
|
237 | 237 | if name.startswith('stream:'): |
|
238 | 238 | def repo_remote_attr(*args, **kwargs): |
|
239 | 239 | return self._call_stream(name, *args, **kwargs) |
|
240 | 240 | else: |
|
241 | 241 | def repo_remote_attr(*args, **kwargs): |
|
242 | 242 | return self._call(name, *args, **kwargs) |
|
243 | 243 | |
|
244 | 244 | return repo_remote_attr |
|
245 | 245 | |
|
246 | 246 | def _base_call(self, name, *args, **kwargs): |
|
247 | 247 | # TODO: oliver: This is currently necessary pre-call since the |
|
248 | 248 | # config object is being changed for hooking scenarios |
|
249 | 249 | wire = copy.deepcopy(self._wire) |
|
250 | 250 | wire["config"] = wire["config"].serialize() |
|
251 | 251 | wire["config"].append(('vcs', 'ssl_dir', self.cert_dir)) |
|
252 | 252 | |
|
253 | 253 | payload = { |
|
254 | 254 | 'id': str(uuid.uuid4()), |
|
255 | 255 | 'method': name, |
|
256 | 256 | "_repo_name": wire['_repo_name'], |
|
257 | 257 | 'params': {'wire': wire, 'args': args, 'kwargs': kwargs} |
|
258 | 258 | } |
|
259 | 259 | |
|
260 | 260 | context_uid = wire.get('context') |
|
261 | 261 | return context_uid, payload |
|
262 | 262 | |
|
263 | 263 | def get_local_cache(self, name, args): |
|
264 | 264 | cache_on = False |
|
265 | 265 | cache_key = '' |
|
266 | 266 | local_cache_on = str2bool(rhodecode.CONFIG.get('vcs.methods.cache')) |
|
267 | 267 | |
|
268 | 268 | cache_methods = [ |
|
269 | 269 | 'branches', 'tags', 'bookmarks', |
|
270 | 270 | 'is_large_file', 'is_binary', |
|
271 | 271 | 'fctx_size', 'stream:fctx_node_data', 'blob_raw_length', |
|
272 | 272 | 'node_history', |
|
273 | 273 | 'revision', 'tree_items', |
|
274 | 274 | 'ctx_list', 'ctx_branch', 'ctx_description', |
|
275 | 275 | 'bulk_request', |
|
276 | 276 | 'assert_correct_path' |
|
277 | 277 | ] |
|
278 | 278 | |
|
279 | 279 | if local_cache_on and name in cache_methods: |
|
280 | 280 | cache_on = True |
|
281 | 281 | repo_state_uid = self._wire['repo_state_uid'] |
|
282 | 282 | call_args = [a for a in args] |
|
283 | 283 | cache_key = compute_key_from_params(repo_state_uid, name, *call_args) |
|
284 | 284 | |
|
285 | 285 | return cache_on, cache_key |
|
286 | 286 | |
|
287 | 287 | @exceptions.map_vcs_exceptions |
|
288 | 288 | def _call(self, name, *args, **kwargs): |
|
289 | 289 | context_uid, payload = self._base_call(name, *args, **kwargs) |
|
290 | 290 | url = self.url |
|
291 | 291 | |
|
292 | 292 | start = time.time() |
|
293 | 293 | cache_on, cache_key = self.get_local_cache(name, args) |
|
294 | 294 | |
|
295 | 295 | @self._cache_region.conditional_cache_on_arguments( |
|
296 | 296 | namespace=self._cache_namespace, condition=cache_on and cache_key) |
|
297 | 297 | def remote_call(_cache_key): |
|
298 | 298 | if self._call_with_logging: |
|
299 | 299 | log.debug('Calling %s@%s with args:%.10240r. wire_context: %s cache_on: %s', |
|
300 | 300 | url, name, args, context_uid, cache_on) |
|
301 | 301 | return _remote_call(url, payload, EXCEPTIONS_MAP, self._session) |
|
302 | 302 | |
|
303 | 303 | result = remote_call(cache_key) |
|
304 | 304 | if self._call_with_logging: |
|
305 | 305 | log.debug('Call %s@%s took: %.4fs. wire_context: %s', |
|
306 | 306 | url, name, time.time()-start, context_uid) |
|
307 | 307 | return result |
|
308 | 308 | |
|
309 | 309 | @exceptions.map_vcs_exceptions |
|
310 | 310 | def _call_stream(self, name, *args, **kwargs): |
|
311 | 311 | context_uid, payload = self._base_call(name, *args, **kwargs) |
|
312 | 312 | payload['chunk_size'] = self.CHUNK_SIZE |
|
313 | 313 | url = self.stream_url |
|
314 | 314 | |
|
315 | 315 | start = time.time() |
|
316 | 316 | cache_on, cache_key = self.get_local_cache(name, args) |
|
317 | 317 | |
|
318 | 318 | # Cache is a problem because this is a stream |
|
319 | 319 | def streaming_remote_call(_cache_key): |
|
320 | 320 | if self._call_with_logging: |
|
321 | 321 | log.debug('Calling %s@%s with args:%.10240r. wire_context: %s cache_on: %s', |
|
322 | 322 | url, name, args, context_uid, cache_on) |
|
323 | 323 | return _streaming_remote_call(url, payload, EXCEPTIONS_MAP, self._session, self.CHUNK_SIZE) |
|
324 | 324 | |
|
325 | 325 | result = streaming_remote_call(cache_key) |
|
326 | 326 | if self._call_with_logging: |
|
327 | 327 | log.debug('Call %s@%s took: %.4fs. wire_context: %s', |
|
328 | 328 | url, name, time.time()-start, context_uid) |
|
329 | 329 | return result |
|
330 | 330 | |
|
331 | 331 | def __getitem__(self, key): |
|
332 | 332 | return self.revision(key) |
|
333 | 333 | |
|
334 | 334 | def _create_vcs_cache_context(self, *args): |
|
335 | 335 | """ |
|
336 | 336 | Creates a unique string which is passed to the VCSServer on every |
|
337 | 337 | remote call. It is used as cache key in the VCSServer. |
|
338 | 338 | """ |
|
339 | 339 | hash_key = '-'.join(map(str, args)) |
|
340 | 340 | return str(uuid.uuid5(uuid.NAMESPACE_URL, hash_key)) |
|
341 | 341 | |
|
342 | 342 | def invalidate_vcs_cache(self): |
|
343 | 343 | """ |
|
344 | 344 | This invalidates the context which is sent to the VCSServer on every |
|
345 | 345 | call to a remote method. It forces the VCSServer to create a fresh |
|
346 | 346 | repository instance on the next call to a remote method. |
|
347 | 347 | """ |
|
348 | 348 | self._wire['context'] = str(uuid.uuid4()) |
|
349 | 349 | |
|
350 | 350 | |
|
351 | 351 | class VcsHttpProxy(object): |
|
352 | 352 | |
|
353 | 353 | CHUNK_SIZE = 16384 |
|
354 | 354 | |
|
355 | 355 | def __init__(self, server_and_port, backend_endpoint): |
|
356 | 356 | retries = Retry(total=5, connect=None, read=None, redirect=None) |
|
357 | 357 | |
|
358 | 358 | adapter = requests.adapters.HTTPAdapter(max_retries=retries) |
|
359 |
self.base_url = urllib.parse |
|
|
359 | self.base_url = urllib.parse.urljoin('http://%s' % server_and_port, backend_endpoint) | |
|
360 | 360 | self.session = requests.Session() |
|
361 | 361 | self.session.mount('http://', adapter) |
|
362 | 362 | |
|
363 | 363 | def handle(self, environment, input_data, *args, **kwargs): |
|
364 | 364 | data = { |
|
365 | 365 | 'environment': environment, |
|
366 | 366 | 'input_data': input_data, |
|
367 | 367 | 'args': args, |
|
368 | 368 | 'kwargs': kwargs |
|
369 | 369 | } |
|
370 | 370 | result = self.session.post( |
|
371 | 371 | self.base_url, msgpack.packb(data), stream=True) |
|
372 | 372 | return self._get_result(result) |
|
373 | 373 | |
|
374 | 374 | def _deserialize_and_raise(self, error): |
|
375 | 375 | exception = Exception(error['message']) |
|
376 | 376 | try: |
|
377 | 377 | exception._vcs_kind = error['_vcs_kind'] |
|
378 | 378 | except KeyError: |
|
379 | 379 | pass |
|
380 | 380 | raise exception |
|
381 | 381 | |
|
382 | 382 | def _iterate(self, result): |
|
383 | 383 | unpacker = msgpack.Unpacker() |
|
384 | 384 | for line in result.iter_content(chunk_size=self.CHUNK_SIZE): |
|
385 | 385 | unpacker.feed(line) |
|
386 | 386 | for chunk in unpacker: |
|
387 | 387 | yield chunk |
|
388 | 388 | |
|
389 | 389 | def _get_result(self, result): |
|
390 | 390 | iterator = self._iterate(result) |
|
391 | 391 | error = next(iterator) |
|
392 | 392 | if error: |
|
393 | 393 | self._deserialize_and_raise(error) |
|
394 | 394 | |
|
395 | 395 | status = next(iterator) |
|
396 | 396 | headers = next(iterator) |
|
397 | 397 | |
|
398 | 398 | return iterator, status, headers |
|
399 | 399 | |
|
400 | 400 | |
|
401 | 401 | class ThreadlocalSessionFactory(object): |
|
402 | 402 | """ |
|
403 | 403 | Creates one CurlSession per thread on demand. |
|
404 | 404 | """ |
|
405 | 405 | |
|
406 | 406 | def __init__(self): |
|
407 | 407 | self._thread_local = threading.local() |
|
408 | 408 | |
|
409 | 409 | def __call__(self): |
|
410 | 410 | if not hasattr(self._thread_local, 'curl_session'): |
|
411 | 411 | self._thread_local.curl_session = CurlSession() |
|
412 | 412 | return self._thread_local.curl_session |
@@ -1,140 +1,140 b'' | |||
|
1 | 1 | # -*- coding: utf-8 -*- |
|
2 | 2 | |
|
3 | 3 | # Copyright (C) 2010-2020 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 pytest |
|
22 | 22 | import urllib.parse |
|
23 | 23 | import mock |
|
24 | 24 | import simplejson as json |
|
25 | 25 | |
|
26 | 26 | from rhodecode.lib.vcs.backends.base import Config |
|
27 | 27 | from rhodecode.tests.lib.middleware import mock_scm_app |
|
28 | 28 | import rhodecode.lib.middleware.simplegit as simplegit |
|
29 | 29 | |
|
30 | 30 | |
|
31 | 31 | def get_environ(url, request_method): |
|
32 | 32 | """Construct a minimum WSGI environ based on the URL.""" |
|
33 |
parsed_url = urllib.parse.urlparse |
|
|
33 | parsed_url = urllib.parse.urlparse(url) | |
|
34 | 34 | environ = { |
|
35 | 35 | 'PATH_INFO': parsed_url.path, |
|
36 | 36 | 'QUERY_STRING': parsed_url.query, |
|
37 | 37 | 'REQUEST_METHOD': request_method, |
|
38 | 38 | } |
|
39 | 39 | |
|
40 | 40 | return environ |
|
41 | 41 | |
|
42 | 42 | |
|
43 | 43 | @pytest.mark.parametrize( |
|
44 | 44 | 'url, expected_action, request_method', |
|
45 | 45 | [ |
|
46 | 46 | ('/foo/bar/info/refs?service=git-upload-pack', 'pull', 'GET'), |
|
47 | 47 | ('/foo/bar/info/refs?service=git-receive-pack', 'push', 'GET'), |
|
48 | 48 | ('/foo/bar/git-upload-pack', 'pull', 'GET'), |
|
49 | 49 | ('/foo/bar/git-receive-pack', 'push', 'GET'), |
|
50 | 50 | # Edge case: missing data for info/refs |
|
51 | 51 | ('/foo/info/refs?service=', 'pull', 'GET'), |
|
52 | 52 | ('/foo/info/refs', 'pull', 'GET'), |
|
53 | 53 | # Edge case: git command comes with service argument |
|
54 | 54 | ('/foo/git-upload-pack?service=git-receive-pack', 'pull', 'GET'), |
|
55 | 55 | ('/foo/git-receive-pack?service=git-upload-pack', 'push', 'GET'), |
|
56 | 56 | # Edge case: repo name conflicts with git commands |
|
57 | 57 | ('/git-receive-pack/git-upload-pack', 'pull', 'GET'), |
|
58 | 58 | ('/git-receive-pack/git-receive-pack', 'push', 'GET'), |
|
59 | 59 | ('/git-upload-pack/git-upload-pack', 'pull', 'GET'), |
|
60 | 60 | ('/git-upload-pack/git-receive-pack', 'push', 'GET'), |
|
61 | 61 | ('/foo/git-receive-pack', 'push', 'GET'), |
|
62 | 62 | # Edge case: not a smart protocol url |
|
63 | 63 | ('/foo/bar', 'pull', 'GET'), |
|
64 | 64 | # GIT LFS cases, batch |
|
65 | 65 | ('/foo/bar/info/lfs/objects/batch', 'push', 'GET'), |
|
66 | 66 | ('/foo/bar/info/lfs/objects/batch', 'pull', 'POST'), |
|
67 | 67 | # GIT LFS oid, dl/upl |
|
68 | 68 | ('/foo/bar/info/lfs/abcdeabcde', 'pull', 'GET'), |
|
69 | 69 | ('/foo/bar/info/lfs/abcdeabcde', 'push', 'PUT'), |
|
70 | 70 | ('/foo/bar/info/lfs/abcdeabcde', 'push', 'POST'), |
|
71 | 71 | # Edge case: repo name conflicts with git commands |
|
72 | 72 | ('/info/lfs/info/lfs/objects/batch', 'push', 'GET'), |
|
73 | 73 | ('/info/lfs/info/lfs/objects/batch', 'pull', 'POST'), |
|
74 | 74 | |
|
75 | 75 | ]) |
|
76 | 76 | def test_get_action(url, expected_action, request_method, baseapp, request_stub): |
|
77 | 77 | app = simplegit.SimpleGit(config={'auth_ret_code': '', 'base_path': ''}, |
|
78 | 78 | registry=request_stub.registry) |
|
79 | 79 | assert expected_action == app._get_action(get_environ(url, request_method)) |
|
80 | 80 | |
|
81 | 81 | |
|
82 | 82 | @pytest.mark.parametrize( |
|
83 | 83 | 'url, expected_repo_name, request_method', |
|
84 | 84 | [ |
|
85 | 85 | ('/foo/info/refs?service=git-upload-pack', 'foo', 'GET'), |
|
86 | 86 | ('/foo/bar/info/refs?service=git-receive-pack', 'foo/bar', 'GET'), |
|
87 | 87 | ('/foo/git-upload-pack', 'foo', 'GET'), |
|
88 | 88 | ('/foo/git-receive-pack', 'foo', 'GET'), |
|
89 | 89 | ('/foo/bar/git-upload-pack', 'foo/bar', 'GET'), |
|
90 | 90 | ('/foo/bar/git-receive-pack', 'foo/bar', 'GET'), |
|
91 | 91 | |
|
92 | 92 | # GIT LFS cases, batch |
|
93 | 93 | ('/foo/bar/info/lfs/objects/batch', 'foo/bar', 'GET'), |
|
94 | 94 | ('/example-git/info/lfs/objects/batch', 'example-git', 'POST'), |
|
95 | 95 | # GIT LFS oid, dl/upl |
|
96 | 96 | ('/foo/info/lfs/abcdeabcde', 'foo', 'GET'), |
|
97 | 97 | ('/foo/bar/info/lfs/abcdeabcde', 'foo/bar', 'PUT'), |
|
98 | 98 | ('/my-git-repo/info/lfs/abcdeabcde', 'my-git-repo', 'POST'), |
|
99 | 99 | # Edge case: repo name conflicts with git commands |
|
100 | 100 | ('/info/lfs/info/lfs/objects/batch', 'info/lfs', 'GET'), |
|
101 | 101 | ('/info/lfs/info/lfs/objects/batch', 'info/lfs', 'POST'), |
|
102 | 102 | |
|
103 | 103 | ]) |
|
104 | 104 | def test_get_repository_name(url, expected_repo_name, request_method, baseapp, request_stub): |
|
105 | 105 | app = simplegit.SimpleGit(config={'auth_ret_code': '', 'base_path': ''}, |
|
106 | 106 | registry=request_stub.registry) |
|
107 | 107 | assert expected_repo_name == app._get_repository_name( |
|
108 | 108 | get_environ(url, request_method)) |
|
109 | 109 | |
|
110 | 110 | |
|
111 | 111 | def test_get_config(user_util, baseapp, request_stub): |
|
112 | 112 | repo = user_util.create_repo(repo_type='git') |
|
113 | 113 | app = simplegit.SimpleGit(config={'auth_ret_code': '', 'base_path': ''}, |
|
114 | 114 | registry=request_stub.registry) |
|
115 | 115 | extras = {'foo': 'FOO', 'bar': 'BAR'} |
|
116 | 116 | |
|
117 | 117 | # We copy the extras as the method below will change the contents. |
|
118 | 118 | git_config = app._create_config(dict(extras), repo_name=repo.repo_name) |
|
119 | 119 | |
|
120 | 120 | expected_config = dict(extras) |
|
121 | 121 | expected_config.update({ |
|
122 | 122 | 'git_update_server_info': False, |
|
123 | 123 | 'git_lfs_enabled': False, |
|
124 | 124 | 'git_lfs_store_path': git_config['git_lfs_store_path'], |
|
125 | 125 | 'git_lfs_http_scheme': 'http' |
|
126 | 126 | }) |
|
127 | 127 | |
|
128 | 128 | assert git_config == expected_config |
|
129 | 129 | |
|
130 | 130 | |
|
131 | 131 | def test_create_wsgi_app_uses_scm_app_from_simplevcs(baseapp, request_stub): |
|
132 | 132 | config = { |
|
133 | 133 | 'auth_ret_code': '', |
|
134 | 134 | 'base_path': '', |
|
135 | 135 | 'vcs.scm_app_implementation': |
|
136 | 136 | 'rhodecode.tests.lib.middleware.mock_scm_app', |
|
137 | 137 | } |
|
138 | 138 | app = simplegit.SimpleGit(config=config, registry=request_stub.registry) |
|
139 | 139 | wsgi_app = app._create_wsgi_app('/tmp/test', 'test_repo', {}) |
|
140 | 140 | assert wsgi_app is mock_scm_app.mock_git_wsgi |
@@ -1,156 +1,156 b'' | |||
|
1 | 1 | # -*- coding: utf-8 -*- |
|
2 | 2 | |
|
3 | 3 | # Copyright (C) 2010-2020 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 urllib.parse |
|
22 | 22 | |
|
23 | 23 | import mock |
|
24 | 24 | import pytest |
|
25 | 25 | import simplejson as json |
|
26 | 26 | |
|
27 | 27 | from rhodecode.lib.vcs.backends.base import Config |
|
28 | 28 | from rhodecode.tests.lib.middleware import mock_scm_app |
|
29 | 29 | import rhodecode.lib.middleware.simplehg as simplehg |
|
30 | 30 | |
|
31 | 31 | |
|
32 | 32 | def get_environ(url): |
|
33 | 33 | """Construct a minimum WSGI environ based on the URL.""" |
|
34 |
parsed_url = urllib.parse.urlparse |
|
|
34 | parsed_url = urllib.parse.urlparse(url) | |
|
35 | 35 | environ = { |
|
36 | 36 | 'PATH_INFO': parsed_url.path, |
|
37 | 37 | 'QUERY_STRING': parsed_url.query, |
|
38 | 38 | } |
|
39 | 39 | |
|
40 | 40 | return environ |
|
41 | 41 | |
|
42 | 42 | |
|
43 | 43 | @pytest.mark.parametrize( |
|
44 | 44 | 'url, expected_action', |
|
45 | 45 | [ |
|
46 | 46 | ('/foo/bar?cmd=unbundle&key=tip', 'push'), |
|
47 | 47 | ('/foo/bar?cmd=pushkey&key=tip', 'push'), |
|
48 | 48 | ('/foo/bar?cmd=listkeys&key=tip', 'pull'), |
|
49 | 49 | ('/foo/bar?cmd=changegroup&key=tip', 'pull'), |
|
50 | 50 | ('/foo/bar?cmd=hello', 'pull'), |
|
51 | 51 | ('/foo/bar?cmd=batch', 'push'), |
|
52 | 52 | ('/foo/bar?cmd=putlfile', 'push'), |
|
53 | 53 | # Edge case: unknown argument: assume push |
|
54 | 54 | ('/foo/bar?cmd=unknown&key=tip', 'push'), |
|
55 | 55 | ('/foo/bar?cmd=&key=tip', 'push'), |
|
56 | 56 | # Edge case: not cmd argument |
|
57 | 57 | ('/foo/bar?key=tip', 'push'), |
|
58 | 58 | ]) |
|
59 | 59 | def test_get_action(url, expected_action, request_stub): |
|
60 | 60 | app = simplehg.SimpleHg(config={'auth_ret_code': '', 'base_path': ''}, |
|
61 | 61 | registry=request_stub.registry) |
|
62 | 62 | assert expected_action == app._get_action(get_environ(url)) |
|
63 | 63 | |
|
64 | 64 | |
|
65 | 65 | @pytest.mark.parametrize( |
|
66 | 66 | 'environ, expected_xargs, expected_batch', |
|
67 | 67 | [ |
|
68 | 68 | ({}, |
|
69 | 69 | [''], ['push']), |
|
70 | 70 | |
|
71 | 71 | ({'HTTP_X_HGARG_1': ''}, |
|
72 | 72 | [''], ['push']), |
|
73 | 73 | |
|
74 | 74 | ({'HTTP_X_HGARG_1': 'cmds=listkeys+namespace%3Dphases'}, |
|
75 | 75 | ['listkeys namespace=phases'], ['pull']), |
|
76 | 76 | |
|
77 | 77 | ({'HTTP_X_HGARG_1': 'cmds=pushkey+namespace%3Dbookmarks%2Ckey%3Dbm%2Cold%3D%2Cnew%3Dcb9a9f314b8b07ba71012fcdbc544b5a4d82ff5b'}, |
|
78 | 78 | ['pushkey namespace=bookmarks,key=bm,old=,new=cb9a9f314b8b07ba71012fcdbc544b5a4d82ff5b'], ['push']), |
|
79 | 79 | |
|
80 | 80 | ({'HTTP_X_HGARG_1': 'namespace=phases'}, |
|
81 | 81 | ['namespace=phases'], ['push']), |
|
82 | 82 | |
|
83 | 83 | ]) |
|
84 | 84 | def test_xarg_and_batch_commands(environ, expected_xargs, expected_batch): |
|
85 | 85 | app = simplehg.SimpleHg |
|
86 | 86 | |
|
87 | 87 | result = app._get_xarg_headers(environ) |
|
88 | 88 | result_batch = app._get_batch_cmd(environ) |
|
89 | 89 | assert expected_xargs == result |
|
90 | 90 | assert expected_batch == result_batch |
|
91 | 91 | |
|
92 | 92 | |
|
93 | 93 | @pytest.mark.parametrize( |
|
94 | 94 | 'url, expected_repo_name', |
|
95 | 95 | [ |
|
96 | 96 | ('/foo?cmd=unbundle&key=tip', 'foo'), |
|
97 | 97 | ('/foo/bar?cmd=pushkey&key=tip', 'foo/bar'), |
|
98 | 98 | ('/foo/bar/baz?cmd=listkeys&key=tip', 'foo/bar/baz'), |
|
99 | 99 | # Repos with trailing slashes. |
|
100 | 100 | ('/foo/?cmd=unbundle&key=tip', 'foo'), |
|
101 | 101 | ('/foo/bar/?cmd=pushkey&key=tip', 'foo/bar'), |
|
102 | 102 | ('/foo/bar/baz/?cmd=listkeys&key=tip', 'foo/bar/baz'), |
|
103 | 103 | ]) |
|
104 | 104 | def test_get_repository_name(url, expected_repo_name, request_stub): |
|
105 | 105 | app = simplehg.SimpleHg(config={'auth_ret_code': '', 'base_path': ''}, |
|
106 | 106 | registry=request_stub.registry) |
|
107 | 107 | assert expected_repo_name == app._get_repository_name(get_environ(url)) |
|
108 | 108 | |
|
109 | 109 | |
|
110 | 110 | def test_get_config(user_util, baseapp, request_stub): |
|
111 | 111 | repo = user_util.create_repo(repo_type='git') |
|
112 | 112 | app = simplehg.SimpleHg(config={'auth_ret_code': '', 'base_path': ''}, |
|
113 | 113 | registry=request_stub.registry) |
|
114 | 114 | extras = [('foo', 'FOO', 'bar', 'BAR')] |
|
115 | 115 | |
|
116 | 116 | hg_config = app._create_config(extras, repo_name=repo.repo_name) |
|
117 | 117 | |
|
118 | 118 | config = simplehg.utils.make_db_config(repo=repo.repo_name) |
|
119 | 119 | config.set('rhodecode', 'RC_SCM_DATA', json.dumps(extras)) |
|
120 | 120 | hg_config_org = config |
|
121 | 121 | |
|
122 | 122 | expected_config = [ |
|
123 | 123 | ('vcs_svn_tag', 'ff89f8c714d135d865f44b90e5413b88de19a55f', '/tags/*'), |
|
124 | 124 | ('web', 'push_ssl', 'False'), |
|
125 | 125 | ('web', 'allow_push', '*'), |
|
126 | 126 | ('web', 'allow_archive', 'gz zip bz2'), |
|
127 | 127 | ('web', 'baseurl', '/'), |
|
128 | 128 | ('vcs_git_lfs', 'store_location', hg_config_org.get('vcs_git_lfs', 'store_location')), |
|
129 | 129 | ('vcs_svn_branch', '9aac1a38c3b8a0cdc4ae0f960a5f83332bc4fa5e', '/branches/*'), |
|
130 | 130 | ('vcs_svn_branch', 'c7e6a611c87da06529fd0dd733308481d67c71a8', '/trunk'), |
|
131 | 131 | ('largefiles', 'usercache', hg_config_org.get('largefiles', 'usercache')), |
|
132 | 132 | ('hooks', 'preoutgoing.pre_pull', 'python:vcsserver.hooks.pre_pull'), |
|
133 | 133 | ('hooks', 'prechangegroup.pre_push', 'python:vcsserver.hooks.pre_push'), |
|
134 | 134 | ('hooks', 'outgoing.pull_logger', 'python:vcsserver.hooks.log_pull_action'), |
|
135 | 135 | ('hooks', 'pretxnchangegroup.pre_push', 'python:vcsserver.hooks.pre_push'), |
|
136 | 136 | ('hooks', 'changegroup.push_logger', 'python:vcsserver.hooks.log_push_action'), |
|
137 | 137 | ('hooks', 'changegroup.repo_size', 'python:vcsserver.hooks.repo_size'), |
|
138 | 138 | ('phases', 'publish', 'True'), |
|
139 | 139 | ('extensions', 'largefiles', ''), |
|
140 | 140 | ('paths', '/', hg_config_org.get('paths', '/')), |
|
141 | 141 | ('rhodecode', 'RC_SCM_DATA', '[["foo", "FOO", "bar", "BAR"]]') |
|
142 | 142 | ] |
|
143 | 143 | for entry in expected_config: |
|
144 | 144 | assert entry in hg_config |
|
145 | 145 | |
|
146 | 146 | |
|
147 | 147 | def test_create_wsgi_app_uses_scm_app_from_simplevcs(request_stub): |
|
148 | 148 | config = { |
|
149 | 149 | 'auth_ret_code': '', |
|
150 | 150 | 'base_path': '', |
|
151 | 151 | 'vcs.scm_app_implementation': |
|
152 | 152 | 'rhodecode.tests.lib.middleware.mock_scm_app', |
|
153 | 153 | } |
|
154 | 154 | app = simplehg.SimpleHg(config=config, registry=request_stub.registry) |
|
155 | 155 | wsgi_app = app._create_wsgi_app('/tmp/test', 'test_repo', {}) |
|
156 | 156 | assert wsgi_app is mock_scm_app.mock_hg_wsgi |
@@ -1,468 +1,467 b'' | |||
|
1 | 1 | # -*- coding: utf-8 -*- |
|
2 | 2 | |
|
3 | 3 | # Copyright (C) 2010-2020 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 threading |
|
22 | 22 | import time |
|
23 | 23 | import logging |
|
24 | 24 | import os.path |
|
25 | 25 | import subprocess |
|
26 | 26 | import tempfile |
|
27 | 27 | import urllib.request, urllib.error, urllib.parse |
|
28 | 28 | from lxml.html import fromstring, tostring |
|
29 | 29 | from lxml.cssselect import CSSSelector |
|
30 | import urllib.parse.urlparse | |
|
31 | 30 | from urllib.parse import unquote_plus |
|
32 | 31 | import webob |
|
33 | 32 | |
|
34 | 33 | from webtest.app import TestResponse, TestApp |
|
35 | 34 | from webtest.compat import print_stderr |
|
36 | 35 | |
|
37 | 36 | import pytest |
|
38 | 37 | import rc_testdata |
|
39 | 38 | |
|
40 | 39 | from rhodecode.model.db import User, Repository |
|
41 | 40 | from rhodecode.model.meta import Session |
|
42 | 41 | from rhodecode.model.scm import ScmModel |
|
43 | 42 | from rhodecode.lib.vcs.backends.svn.repository import SubversionRepository |
|
44 | 43 | from rhodecode.lib.vcs.backends.base import EmptyCommit |
|
45 | 44 | from rhodecode.tests import login_user_session |
|
46 | 45 | |
|
47 | 46 | log = logging.getLogger(__name__) |
|
48 | 47 | |
|
49 | 48 | |
|
50 | 49 | class CustomTestResponse(TestResponse): |
|
51 | 50 | |
|
52 | 51 | def _save_output(self, out): |
|
53 | 52 | f = tempfile.NamedTemporaryFile(delete=False, prefix='rc-test-', suffix='.html') |
|
54 | 53 | f.write(out) |
|
55 | 54 | return f.name |
|
56 | 55 | |
|
57 | 56 | def mustcontain(self, *strings, **kw): |
|
58 | 57 | """ |
|
59 | 58 | Assert that the response contains all of the strings passed |
|
60 | 59 | in as arguments. |
|
61 | 60 | |
|
62 | 61 | Equivalent to:: |
|
63 | 62 | |
|
64 | 63 | assert string in res |
|
65 | 64 | """ |
|
66 | 65 | print_body = kw.pop('print_body', False) |
|
67 | 66 | if 'no' in kw: |
|
68 | 67 | no = kw['no'] |
|
69 | 68 | del kw['no'] |
|
70 | 69 | if isinstance(no, str): |
|
71 | 70 | no = [no] |
|
72 | 71 | else: |
|
73 | 72 | no = [] |
|
74 | 73 | if kw: |
|
75 | 74 | raise TypeError( |
|
76 | 75 | "The only keyword argument allowed is 'no' got %s" % kw) |
|
77 | 76 | |
|
78 | 77 | f = self._save_output(str(self)) |
|
79 | 78 | |
|
80 | 79 | for s in strings: |
|
81 | 80 | if not s in self: |
|
82 | 81 | print_stderr("Actual response (no %r):" % s) |
|
83 | 82 | print_stderr("body output saved as `%s`" % f) |
|
84 | 83 | if print_body: |
|
85 | 84 | print_stderr(str(self)) |
|
86 | 85 | raise IndexError( |
|
87 | 86 | "Body does not contain string %r, body output saved as %s" % (s, f)) |
|
88 | 87 | |
|
89 | 88 | for no_s in no: |
|
90 | 89 | if no_s in self: |
|
91 | 90 | print_stderr("Actual response (has %r)" % no_s) |
|
92 | 91 | print_stderr("body output saved as `%s`" % f) |
|
93 | 92 | if print_body: |
|
94 | 93 | print_stderr(str(self)) |
|
95 | 94 | raise IndexError( |
|
96 | 95 | "Body contains bad string %r, body output saved as %s" % (no_s, f)) |
|
97 | 96 | |
|
98 | 97 | def assert_response(self): |
|
99 | 98 | return AssertResponse(self) |
|
100 | 99 | |
|
101 | 100 | def get_session_from_response(self): |
|
102 | 101 | """ |
|
103 | 102 | This returns the session from a response object. |
|
104 | 103 | """ |
|
105 | 104 | from rhodecode.lib.rc_beaker import session_factory_from_settings |
|
106 | 105 | session = session_factory_from_settings(self.test_app._pyramid_settings) |
|
107 | 106 | return session(self.request) |
|
108 | 107 | |
|
109 | 108 | |
|
110 | 109 | class TestRequest(webob.BaseRequest): |
|
111 | 110 | |
|
112 | 111 | # for py.test |
|
113 | 112 | disabled = True |
|
114 | 113 | ResponseClass = CustomTestResponse |
|
115 | 114 | |
|
116 | 115 | def add_response_callback(self, callback): |
|
117 | 116 | pass |
|
118 | 117 | |
|
119 | 118 | |
|
120 | 119 | class CustomTestApp(TestApp): |
|
121 | 120 | """ |
|
122 | 121 | Custom app to make mustcontain more Useful, and extract special methods |
|
123 | 122 | """ |
|
124 | 123 | RequestClass = TestRequest |
|
125 | 124 | rc_login_data = {} |
|
126 | 125 | rc_current_session = None |
|
127 | 126 | |
|
128 | 127 | def login(self, username=None, password=None): |
|
129 | 128 | from rhodecode.lib import auth |
|
130 | 129 | |
|
131 | 130 | if username and password: |
|
132 | 131 | session = login_user_session(self, username, password) |
|
133 | 132 | else: |
|
134 | 133 | session = login_user_session(self) |
|
135 | 134 | |
|
136 | 135 | self.rc_login_data['csrf_token'] = auth.get_csrf_token(session) |
|
137 | 136 | self.rc_current_session = session |
|
138 | 137 | return session['rhodecode_user'] |
|
139 | 138 | |
|
140 | 139 | @property |
|
141 | 140 | def csrf_token(self): |
|
142 | 141 | return self.rc_login_data['csrf_token'] |
|
143 | 142 | |
|
144 | 143 | @property |
|
145 | 144 | def _pyramid_registry(self): |
|
146 | 145 | return self.app.config.registry |
|
147 | 146 | |
|
148 | 147 | @property |
|
149 | 148 | def _pyramid_settings(self): |
|
150 | 149 | return self._pyramid_registry.settings |
|
151 | 150 | |
|
152 | 151 | |
|
153 | 152 | def set_anonymous_access(enabled): |
|
154 | 153 | """(Dis)allows anonymous access depending on parameter `enabled`""" |
|
155 | 154 | user = User.get_default_user() |
|
156 | 155 | user.active = enabled |
|
157 | 156 | Session().add(user) |
|
158 | 157 | Session().commit() |
|
159 | 158 | time.sleep(1.5) # must sleep for cache (1s to expire) |
|
160 | 159 | log.info('anonymous access is now: %s', enabled) |
|
161 | 160 | assert enabled == User.get_default_user().active, ( |
|
162 | 161 | 'Cannot set anonymous access') |
|
163 | 162 | |
|
164 | 163 | |
|
165 | 164 | def check_xfail_backends(node, backend_alias): |
|
166 | 165 | # Using "xfail_backends" here intentionally, since this marks work |
|
167 | 166 | # which is "to be done" soon. |
|
168 | 167 | skip_marker = node.get_closest_marker('xfail_backends') |
|
169 | 168 | if skip_marker and backend_alias in skip_marker.args: |
|
170 | 169 | msg = "Support for backend %s to be developed." % (backend_alias, ) |
|
171 | 170 | msg = skip_marker.kwargs.get('reason', msg) |
|
172 | 171 | pytest.xfail(msg) |
|
173 | 172 | |
|
174 | 173 | |
|
175 | 174 | def check_skip_backends(node, backend_alias): |
|
176 | 175 | # Using "skip_backends" here intentionally, since this marks work which is |
|
177 | 176 | # not supported. |
|
178 | 177 | skip_marker = node.get_closest_marker('skip_backends') |
|
179 | 178 | if skip_marker and backend_alias in skip_marker.args: |
|
180 | 179 | msg = "Feature not supported for backend %s." % (backend_alias, ) |
|
181 | 180 | msg = skip_marker.kwargs.get('reason', msg) |
|
182 | 181 | pytest.skip(msg) |
|
183 | 182 | |
|
184 | 183 | |
|
185 | 184 | def extract_git_repo_from_dump(dump_name, repo_name): |
|
186 | 185 | """Create git repo `repo_name` from dump `dump_name`.""" |
|
187 | 186 | repos_path = ScmModel().repos_path |
|
188 | 187 | target_path = os.path.join(repos_path, repo_name) |
|
189 | 188 | rc_testdata.extract_git_dump(dump_name, target_path) |
|
190 | 189 | return target_path |
|
191 | 190 | |
|
192 | 191 | |
|
193 | 192 | def extract_hg_repo_from_dump(dump_name, repo_name): |
|
194 | 193 | """Create hg repo `repo_name` from dump `dump_name`.""" |
|
195 | 194 | repos_path = ScmModel().repos_path |
|
196 | 195 | target_path = os.path.join(repos_path, repo_name) |
|
197 | 196 | rc_testdata.extract_hg_dump(dump_name, target_path) |
|
198 | 197 | return target_path |
|
199 | 198 | |
|
200 | 199 | |
|
201 | 200 | def extract_svn_repo_from_dump(dump_name, repo_name): |
|
202 | 201 | """Create a svn repo `repo_name` from dump `dump_name`.""" |
|
203 | 202 | repos_path = ScmModel().repos_path |
|
204 | 203 | target_path = os.path.join(repos_path, repo_name) |
|
205 | 204 | SubversionRepository(target_path, create=True) |
|
206 | 205 | _load_svn_dump_into_repo(dump_name, target_path) |
|
207 | 206 | return target_path |
|
208 | 207 | |
|
209 | 208 | |
|
210 | 209 | def assert_message_in_log(log_records, message, levelno, module): |
|
211 | 210 | messages = [ |
|
212 | 211 | r.message for r in log_records |
|
213 | 212 | if r.module == module and r.levelno == levelno |
|
214 | 213 | ] |
|
215 | 214 | assert message in messages |
|
216 | 215 | |
|
217 | 216 | |
|
218 | 217 | def _load_svn_dump_into_repo(dump_name, repo_path): |
|
219 | 218 | """ |
|
220 | 219 | Utility to populate a svn repository with a named dump |
|
221 | 220 | |
|
222 | 221 | Currently the dumps are in rc_testdata. They might later on be |
|
223 | 222 | integrated with the main repository once they stabilize more. |
|
224 | 223 | """ |
|
225 | 224 | dump = rc_testdata.load_svn_dump(dump_name) |
|
226 | 225 | load_dump = subprocess.Popen( |
|
227 | 226 | ['svnadmin', 'load', repo_path], |
|
228 | 227 | stdin=subprocess.PIPE, stdout=subprocess.PIPE, |
|
229 | 228 | stderr=subprocess.PIPE) |
|
230 | 229 | out, err = load_dump.communicate(dump) |
|
231 | 230 | if load_dump.returncode != 0: |
|
232 | 231 | log.error("Output of load_dump command: %s", out) |
|
233 | 232 | log.error("Error output of load_dump command: %s", err) |
|
234 | 233 | raise Exception( |
|
235 | 234 | 'Failed to load dump "%s" into repository at path "%s".' |
|
236 | 235 | % (dump_name, repo_path)) |
|
237 | 236 | |
|
238 | 237 | |
|
239 | 238 | class AssertResponse(object): |
|
240 | 239 | """ |
|
241 | 240 | Utility that helps to assert things about a given HTML response. |
|
242 | 241 | """ |
|
243 | 242 | |
|
244 | 243 | def __init__(self, response): |
|
245 | 244 | self.response = response |
|
246 | 245 | |
|
247 | 246 | def get_imports(self): |
|
248 | 247 | return fromstring, tostring, CSSSelector |
|
249 | 248 | |
|
250 | 249 | def one_element_exists(self, css_selector): |
|
251 | 250 | self.get_element(css_selector) |
|
252 | 251 | |
|
253 | 252 | def no_element_exists(self, css_selector): |
|
254 | 253 | assert not self._get_elements(css_selector) |
|
255 | 254 | |
|
256 | 255 | def element_equals_to(self, css_selector, expected_content): |
|
257 | 256 | element = self.get_element(css_selector) |
|
258 | 257 | element_text = self._element_to_string(element) |
|
259 | 258 | assert expected_content in element_text |
|
260 | 259 | |
|
261 | 260 | def element_contains(self, css_selector, expected_content): |
|
262 | 261 | element = self.get_element(css_selector) |
|
263 | 262 | assert expected_content in element.text_content() |
|
264 | 263 | |
|
265 | 264 | def element_value_contains(self, css_selector, expected_content): |
|
266 | 265 | element = self.get_element(css_selector) |
|
267 | 266 | assert expected_content in element.value |
|
268 | 267 | |
|
269 | 268 | def contains_one_link(self, link_text, href): |
|
270 | 269 | fromstring, tostring, CSSSelector = self.get_imports() |
|
271 | 270 | doc = fromstring(self.response.body) |
|
272 | 271 | sel = CSSSelector('a[href]') |
|
273 | 272 | elements = [ |
|
274 | 273 | e for e in sel(doc) if e.text_content().strip() == link_text] |
|
275 | 274 | assert len(elements) == 1, "Did not find link or found multiple links" |
|
276 | 275 | self._ensure_url_equal(elements[0].attrib.get('href'), href) |
|
277 | 276 | |
|
278 | 277 | def contains_one_anchor(self, anchor_id): |
|
279 | 278 | fromstring, tostring, CSSSelector = self.get_imports() |
|
280 | 279 | doc = fromstring(self.response.body) |
|
281 | 280 | sel = CSSSelector('#' + anchor_id) |
|
282 | 281 | elements = sel(doc) |
|
283 | 282 | assert len(elements) == 1, 'cannot find 1 element {}'.format(anchor_id) |
|
284 | 283 | |
|
285 | 284 | def _ensure_url_equal(self, found, expected): |
|
286 | 285 | assert _Url(found) == _Url(expected) |
|
287 | 286 | |
|
288 | 287 | def get_element(self, css_selector): |
|
289 | 288 | elements = self._get_elements(css_selector) |
|
290 | 289 | assert len(elements) == 1, 'cannot find 1 element {}'.format(css_selector) |
|
291 | 290 | return elements[0] |
|
292 | 291 | |
|
293 | 292 | def get_elements(self, css_selector): |
|
294 | 293 | return self._get_elements(css_selector) |
|
295 | 294 | |
|
296 | 295 | def _get_elements(self, css_selector): |
|
297 | 296 | fromstring, tostring, CSSSelector = self.get_imports() |
|
298 | 297 | doc = fromstring(self.response.body) |
|
299 | 298 | sel = CSSSelector(css_selector) |
|
300 | 299 | elements = sel(doc) |
|
301 | 300 | return elements |
|
302 | 301 | |
|
303 | 302 | def _element_to_string(self, element): |
|
304 | 303 | fromstring, tostring, CSSSelector = self.get_imports() |
|
305 | 304 | return tostring(element) |
|
306 | 305 | |
|
307 | 306 | |
|
308 | 307 | class _Url(object): |
|
309 | 308 | """ |
|
310 | 309 | A url object that can be compared with other url orbjects |
|
311 | 310 | without regard to the vagaries of encoding, escaping, and ordering |
|
312 | 311 | of parameters in query strings. |
|
313 | 312 | |
|
314 | 313 | Inspired by |
|
315 | 314 | http://stackoverflow.com/questions/5371992/comparing-two-urls-in-python |
|
316 | 315 | """ |
|
317 | 316 | |
|
318 | 317 | def __init__(self, url): |
|
319 | 318 | parts = urllib.parse.urlparse(url) |
|
320 | 319 | _query = frozenset(urllib.parse.parse_qsl(parts.query)) |
|
321 | 320 | _path = unquote_plus(parts.path) |
|
322 | 321 | parts = parts._replace(query=_query, path=_path) |
|
323 | 322 | self.parts = parts |
|
324 | 323 | |
|
325 | 324 | def __eq__(self, other): |
|
326 | 325 | return self.parts == other.parts |
|
327 | 326 | |
|
328 | 327 | def __hash__(self): |
|
329 | 328 | return hash(self.parts) |
|
330 | 329 | |
|
331 | 330 | |
|
332 | 331 | def run_test_concurrently(times, raise_catched_exc=True): |
|
333 | 332 | """ |
|
334 | 333 | Add this decorator to small pieces of code that you want to test |
|
335 | 334 | concurrently |
|
336 | 335 | |
|
337 | 336 | ex: |
|
338 | 337 | |
|
339 | 338 | @test_concurrently(25) |
|
340 | 339 | def my_test_function(): |
|
341 | 340 | ... |
|
342 | 341 | """ |
|
343 | 342 | def test_concurrently_decorator(test_func): |
|
344 | 343 | def wrapper(*args, **kwargs): |
|
345 | 344 | exceptions = [] |
|
346 | 345 | |
|
347 | 346 | def call_test_func(): |
|
348 | 347 | try: |
|
349 | 348 | test_func(*args, **kwargs) |
|
350 | 349 | except Exception as e: |
|
351 | 350 | exceptions.append(e) |
|
352 | 351 | if raise_catched_exc: |
|
353 | 352 | raise |
|
354 | 353 | threads = [] |
|
355 | 354 | for i in range(times): |
|
356 | 355 | threads.append(threading.Thread(target=call_test_func)) |
|
357 | 356 | for t in threads: |
|
358 | 357 | t.start() |
|
359 | 358 | for t in threads: |
|
360 | 359 | t.join() |
|
361 | 360 | if exceptions: |
|
362 | 361 | raise Exception( |
|
363 | 362 | 'test_concurrently intercepted %s exceptions: %s' % ( |
|
364 | 363 | len(exceptions), exceptions)) |
|
365 | 364 | return wrapper |
|
366 | 365 | return test_concurrently_decorator |
|
367 | 366 | |
|
368 | 367 | |
|
369 | 368 | def wait_for_url(url, timeout=10): |
|
370 | 369 | """ |
|
371 | 370 | Wait until URL becomes reachable. |
|
372 | 371 | |
|
373 | 372 | It polls the URL until the timeout is reached or it became reachable. |
|
374 | 373 | If will call to `py.test.fail` in case the URL is not reachable. |
|
375 | 374 | """ |
|
376 | 375 | timeout = time.time() + timeout |
|
377 | 376 | last = 0 |
|
378 | 377 | wait = 0.1 |
|
379 | 378 | |
|
380 | 379 | while timeout > last: |
|
381 | 380 | last = time.time() |
|
382 | 381 | if is_url_reachable(url): |
|
383 | 382 | break |
|
384 | 383 | elif (last + wait) > time.time(): |
|
385 | 384 | # Go to sleep because not enough time has passed since last check. |
|
386 | 385 | time.sleep(wait) |
|
387 | 386 | else: |
|
388 | 387 | pytest.fail("Timeout while waiting for URL {}".format(url)) |
|
389 | 388 | |
|
390 | 389 | |
|
391 | 390 | def is_url_reachable(url): |
|
392 | 391 | try: |
|
393 | 392 | urllib.request.urlopen(url) |
|
394 | 393 | except urllib.error.URLError: |
|
395 | 394 | log.exception('URL `{}` reach error'.format(url)) |
|
396 | 395 | return False |
|
397 | 396 | return True |
|
398 | 397 | |
|
399 | 398 | |
|
400 | 399 | def repo_on_filesystem(repo_name): |
|
401 | 400 | from rhodecode.lib import vcs |
|
402 | 401 | from rhodecode.tests import TESTS_TMP_PATH |
|
403 | 402 | repo = vcs.get_vcs_instance( |
|
404 | 403 | os.path.join(TESTS_TMP_PATH, repo_name), create=False) |
|
405 | 404 | return repo is not None |
|
406 | 405 | |
|
407 | 406 | |
|
408 | 407 | def commit_change( |
|
409 | 408 | repo, filename, content, message, vcs_type, parent=None, newfile=False): |
|
410 | 409 | from rhodecode.tests import TEST_USER_ADMIN_LOGIN |
|
411 | 410 | |
|
412 | 411 | repo = Repository.get_by_repo_name(repo) |
|
413 | 412 | _commit = parent |
|
414 | 413 | if not parent: |
|
415 | 414 | _commit = EmptyCommit(alias=vcs_type) |
|
416 | 415 | |
|
417 | 416 | if newfile: |
|
418 | 417 | nodes = { |
|
419 | 418 | filename: { |
|
420 | 419 | 'content': content |
|
421 | 420 | } |
|
422 | 421 | } |
|
423 | 422 | commit = ScmModel().create_nodes( |
|
424 | 423 | user=TEST_USER_ADMIN_LOGIN, repo=repo, |
|
425 | 424 | message=message, |
|
426 | 425 | nodes=nodes, |
|
427 | 426 | parent_commit=_commit, |
|
428 | 427 | author='{} <admin@rhodecode.com>'.format(TEST_USER_ADMIN_LOGIN), |
|
429 | 428 | ) |
|
430 | 429 | else: |
|
431 | 430 | commit = ScmModel().commit_change( |
|
432 | 431 | repo=repo.scm_instance(), repo_name=repo.repo_name, |
|
433 | 432 | commit=parent, user=TEST_USER_ADMIN_LOGIN, |
|
434 | 433 | author='{} <admin@rhodecode.com>'.format(TEST_USER_ADMIN_LOGIN), |
|
435 | 434 | message=message, |
|
436 | 435 | content=content, |
|
437 | 436 | f_path=filename |
|
438 | 437 | ) |
|
439 | 438 | return commit |
|
440 | 439 | |
|
441 | 440 | |
|
442 | 441 | def permission_update_data_generator(csrf_token, default=None, grant=None, revoke=None): |
|
443 | 442 | if not default: |
|
444 | 443 | raise ValueError('Permission for default user must be given') |
|
445 | 444 | form_data = [( |
|
446 | 445 | 'csrf_token', csrf_token |
|
447 | 446 | )] |
|
448 | 447 | # add default |
|
449 | 448 | form_data.extend([ |
|
450 | 449 | ('u_perm_1', default) |
|
451 | 450 | ]) |
|
452 | 451 | |
|
453 | 452 | if grant: |
|
454 | 453 | for cnt, (obj_id, perm, obj_name, obj_type) in enumerate(grant, 1): |
|
455 | 454 | form_data.extend([ |
|
456 | 455 | ('perm_new_member_perm_new{}'.format(cnt), perm), |
|
457 | 456 | ('perm_new_member_id_new{}'.format(cnt), obj_id), |
|
458 | 457 | ('perm_new_member_name_new{}'.format(cnt), obj_name), |
|
459 | 458 | ('perm_new_member_type_new{}'.format(cnt), obj_type), |
|
460 | 459 | |
|
461 | 460 | ]) |
|
462 | 461 | if revoke: |
|
463 | 462 | for obj_id, obj_type in revoke: |
|
464 | 463 | form_data.extend([ |
|
465 | 464 | ('perm_del_member_id_{}'.format(obj_id), obj_id), |
|
466 | 465 | ('perm_del_member_type_{}'.format(obj_id), obj_type), |
|
467 | 466 | ]) |
|
468 | 467 | return form_data |
General Comments 0
You need to be logged in to leave comments.
Login now