|
1 # copyright 2003-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved. |
|
2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr |
|
3 # |
|
4 # This file is part of CubicWeb. |
|
5 # |
|
6 # CubicWeb is free software: you can redistribute it and/or modify it under the |
|
7 # terms of the GNU Lesser General Public License as published by the Free |
|
8 # Software Foundation, either version 2.1 of the License, or (at your option) |
|
9 # any later version. |
|
10 # |
|
11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT |
|
12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS |
|
13 # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more |
|
14 # details. |
|
15 # |
|
16 # You should have received a copy of the GNU Lesser General Public License along |
|
17 # with CubicWeb. If not, see <http://www.gnu.org/licenses/>. |
|
18 """CubicWeb web client application object""" |
|
19 |
|
20 __docformat__ = "restructuredtext en" |
|
21 |
|
22 import sys |
|
23 from time import clock, time |
|
24 from contextlib import contextmanager |
|
25 from warnings import warn |
|
26 import json |
|
27 |
|
28 from six import text_type, binary_type |
|
29 from six.moves import http_client |
|
30 |
|
31 from logilab.common.deprecation import deprecated |
|
32 |
|
33 from rql import BadRQLQuery |
|
34 |
|
35 from cubicweb import set_log_methods, cwvreg |
|
36 from cubicweb import ( |
|
37 ValidationError, Unauthorized, Forbidden, |
|
38 AuthenticationError, NoSelectableObject, |
|
39 CW_EVENT_MANAGER) |
|
40 from cubicweb.repoapi import anonymous_cnx |
|
41 from cubicweb.web import LOGGER, component, cors |
|
42 from cubicweb.web import ( |
|
43 StatusResponse, DirectResponse, Redirect, NotFound, LogOut, |
|
44 RemoteCallFailed, InvalidSession, RequestError, PublishException) |
|
45 |
|
46 from cubicweb.web.request import CubicWebRequestBase |
|
47 |
|
48 # make session manager available through a global variable so the debug view can |
|
49 # print information about web session |
|
50 SESSION_MANAGER = None |
|
51 |
|
52 |
|
53 @contextmanager |
|
54 def anonymized_request(req): |
|
55 orig_cnx = req.cnx |
|
56 anon_cnx = anonymous_cnx(orig_cnx.session.repo) |
|
57 req.set_cnx(anon_cnx) |
|
58 try: |
|
59 with anon_cnx: |
|
60 yield req |
|
61 finally: |
|
62 req.set_cnx(orig_cnx) |
|
63 |
|
64 |
|
65 |
|
66 class CookieSessionHandler(object): |
|
67 """a session handler using a cookie to store the session identifier""" |
|
68 |
|
69 def __init__(self, appli): |
|
70 self.repo = appli.repo |
|
71 self.vreg = appli.vreg |
|
72 self.session_manager = self.vreg['sessions'].select('sessionmanager', |
|
73 repo=self.repo) |
|
74 global SESSION_MANAGER |
|
75 SESSION_MANAGER = self.session_manager |
|
76 if self.vreg.config.mode != 'test': |
|
77 # don't try to reset session manager during test, this leads to |
|
78 # weird failures when running multiple tests |
|
79 CW_EVENT_MANAGER.bind('after-registry-reload', |
|
80 self.reset_session_manager) |
|
81 |
|
82 def reset_session_manager(self): |
|
83 data = self.session_manager.dump_data() |
|
84 self.session_manager = self.vreg['sessions'].select('sessionmanager', |
|
85 repo=self.repo) |
|
86 self.session_manager.restore_data(data) |
|
87 global SESSION_MANAGER |
|
88 SESSION_MANAGER = self.session_manager |
|
89 |
|
90 @property |
|
91 def clean_sessions_interval(self): |
|
92 return self.session_manager.clean_sessions_interval |
|
93 |
|
94 def clean_sessions(self): |
|
95 """cleanup sessions which has not been unused since a given amount of |
|
96 time |
|
97 """ |
|
98 self.session_manager.clean_sessions() |
|
99 |
|
100 def session_cookie(self, req): |
|
101 """return a string giving the name of the cookie used to store the |
|
102 session identifier. |
|
103 """ |
|
104 if req.https: |
|
105 return '__%s_https_session' % self.vreg.config.appid |
|
106 return '__%s_session' % self.vreg.config.appid |
|
107 |
|
108 def get_session(self, req): |
|
109 """Return a session object corresponding to credentials held by the req |
|
110 |
|
111 Session id is searched from : |
|
112 - # form variable |
|
113 - cookie |
|
114 |
|
115 If no session id is found, try opening a new session with credentials |
|
116 found in the request. |
|
117 |
|
118 Raises AuthenticationError if no session can be found or created. |
|
119 """ |
|
120 cookie = req.get_cookie() |
|
121 sessioncookie = self.session_cookie(req) |
|
122 try: |
|
123 sessionid = str(cookie[sessioncookie].value) |
|
124 session = self.get_session_by_id(req, sessionid) |
|
125 except (KeyError, InvalidSession): # no valid session cookie |
|
126 session = self.open_session(req) |
|
127 return session |
|
128 |
|
129 def get_session_by_id(self, req, sessionid): |
|
130 session = self.session_manager.get_session(req, sessionid) |
|
131 session.mtime = time() |
|
132 return session |
|
133 |
|
134 def open_session(self, req): |
|
135 session = self.session_manager.open_session(req) |
|
136 sessioncookie = self.session_cookie(req) |
|
137 secure = req.https and req.base_url().startswith('https://') |
|
138 req.set_cookie(sessioncookie, session.sessionid, |
|
139 maxage=None, secure=secure, httponly=True) |
|
140 if not session.anonymous_session: |
|
141 self.session_manager.postlogin(req, session) |
|
142 return session |
|
143 |
|
144 def logout(self, req, goto_url): |
|
145 """logout from the instance by cleaning the session and raising |
|
146 `AuthenticationError` |
|
147 """ |
|
148 self.session_manager.close_session(req.session) |
|
149 req.remove_cookie(self.session_cookie(req)) |
|
150 raise LogOut(url=goto_url) |
|
151 |
|
152 # these are overridden by set_log_methods below |
|
153 # only defining here to prevent pylint from complaining |
|
154 info = warning = error = critical = exception = debug = lambda msg,*a,**kw: None |
|
155 |
|
156 class CubicWebPublisher(object): |
|
157 """the publisher is a singleton hold by the web frontend, and is responsible |
|
158 to publish HTTP request. |
|
159 |
|
160 The http server will call its main entry point ``application.handle_request``. |
|
161 |
|
162 .. automethod:: cubicweb.web.application.CubicWebPublisher.main_handle_request |
|
163 |
|
164 You have to provide both a repository and web-server config at |
|
165 initialization. In all in one instance both config will be the same. |
|
166 """ |
|
167 |
|
168 def __init__(self, repo, config, session_handler_fact=CookieSessionHandler): |
|
169 self.info('starting web instance from %s', config.apphome) |
|
170 self.repo = repo |
|
171 self.vreg = repo.vreg |
|
172 # get instance's schema |
|
173 if not self.vreg.initialized: |
|
174 config.init_cubes(self.repo.get_cubes()) |
|
175 self.vreg.init_properties(self.repo.properties()) |
|
176 self.vreg.set_schema(self.repo.get_schema()) |
|
177 # set the correct publish method |
|
178 if config['query-log-file']: |
|
179 from threading import Lock |
|
180 self._query_log = open(config['query-log-file'], 'a') |
|
181 self.handle_request = self.log_handle_request |
|
182 self._logfile_lock = Lock() |
|
183 else: |
|
184 self._query_log = None |
|
185 self.handle_request = self.main_handle_request |
|
186 # instantiate session and url resolving helpers |
|
187 self.session_handler = session_handler_fact(self) |
|
188 self.set_urlresolver() |
|
189 CW_EVENT_MANAGER.bind('after-registry-reload', self.set_urlresolver) |
|
190 |
|
191 def set_urlresolver(self): |
|
192 self.url_resolver = self.vreg['components'].select('urlpublisher', |
|
193 vreg=self.vreg) |
|
194 |
|
195 def get_session(self, req): |
|
196 """Return a session object corresponding to credentials held by the req |
|
197 |
|
198 May raise AuthenticationError. |
|
199 """ |
|
200 return self.session_handler.get_session(req) |
|
201 |
|
202 # publish methods ######################################################### |
|
203 |
|
204 def log_handle_request(self, req, path): |
|
205 """wrapper around _publish to log all queries executed for a given |
|
206 accessed path |
|
207 """ |
|
208 def wrap_set_cnx(func): |
|
209 def wrap_execute(cnx): |
|
210 orig_execute = cnx.execute |
|
211 def execute(rql, kwargs=None, build_descr=True): |
|
212 tstart, cstart = time(), clock() |
|
213 rset = orig_execute(rql, kwargs, build_descr=build_descr) |
|
214 cnx.executed_queries.append((rql, kwargs, time() - tstart, clock() - cstart)) |
|
215 return rset |
|
216 return execute |
|
217 def set_cnx(cnx): |
|
218 func(cnx) |
|
219 cnx.execute = wrap_execute(cnx) |
|
220 cnx.executed_queries = [] |
|
221 return set_cnx |
|
222 req.set_cnx = wrap_set_cnx(req.set_cnx) |
|
223 try: |
|
224 return self.main_handle_request(req, path) |
|
225 finally: |
|
226 cnx = req.cnx |
|
227 if cnx: |
|
228 with self._logfile_lock: |
|
229 try: |
|
230 result = ['\n'+'*'*80] |
|
231 result.append(req.url()) |
|
232 result += ['%s %s -- (%.3f sec, %.3f CPU sec)' % q |
|
233 for q in cnx.executed_queries] |
|
234 cnx.executed_queries = [] |
|
235 self._query_log.write('\n'.join(result).encode(req.encoding)) |
|
236 self._query_log.flush() |
|
237 except Exception: |
|
238 self.exception('error while logging queries') |
|
239 |
|
240 |
|
241 |
|
242 def main_handle_request(self, req, path): |
|
243 """Process an http request |
|
244 |
|
245 Arguments are: |
|
246 - a Request object |
|
247 - path of the request object |
|
248 |
|
249 It returns the content of the http response. HTTP header and status are |
|
250 set on the Request object. |
|
251 """ |
|
252 if not isinstance(req, CubicWebRequestBase): |
|
253 warn('[3.15] Application entry point arguments are now (req, path) ' |
|
254 'not (path, req)', DeprecationWarning, 2) |
|
255 req, path = path, req |
|
256 if req.authmode == 'http': |
|
257 # activate realm-based auth |
|
258 realm = self.vreg.config['realm'] |
|
259 req.set_header('WWW-Authenticate', [('Basic', {'realm' : realm })], raw=False) |
|
260 content = b'' |
|
261 try: |
|
262 try: |
|
263 session = self.get_session(req) |
|
264 from cubicweb import repoapi |
|
265 cnx = repoapi.Connection(session) |
|
266 req.set_cnx(cnx) |
|
267 except AuthenticationError: |
|
268 # Keep the dummy session set at initialisation. |
|
269 # such session with work to an some extend but raise an |
|
270 # AuthenticationError on any database access. |
|
271 import contextlib |
|
272 @contextlib.contextmanager |
|
273 def dummy(): |
|
274 yield |
|
275 cnx = dummy() |
|
276 # XXX We want to clean up this approach in the future. But |
|
277 # several cubes like registration or forgotten password rely on |
|
278 # this principle. |
|
279 |
|
280 # nested try to allow LogOut to delegate logic to AuthenticationError |
|
281 # handler |
|
282 try: |
|
283 ### Try to generate the actual request content |
|
284 with cnx: |
|
285 content = self.core_handle(req, path) |
|
286 # Handle user log-out |
|
287 except LogOut as ex: |
|
288 # When authentification is handled by cookie the code that |
|
289 # raised LogOut must has invalidated the cookie. We can just |
|
290 # reload the original url without authentification |
|
291 if self.vreg.config['auth-mode'] == 'cookie' and ex.url: |
|
292 req.headers_out.setHeader('location', str(ex.url)) |
|
293 if ex.status is not None: |
|
294 req.status_out = http_client.SEE_OTHER |
|
295 # When the authentification is handled by http we must |
|
296 # explicitly ask for authentification to flush current http |
|
297 # authentification information |
|
298 else: |
|
299 # Render "logged out" content. |
|
300 # assignement to ``content`` prevent standard |
|
301 # AuthenticationError code to overwrite it. |
|
302 content = self.loggedout_content(req) |
|
303 # let the explicitly reset http credential |
|
304 raise AuthenticationError() |
|
305 except Redirect as ex: |
|
306 # authentication needs redirection (eg openid) |
|
307 content = self.redirect_handler(req, ex) |
|
308 # Wrong, absent or Reseted credential |
|
309 except AuthenticationError: |
|
310 # If there is an https url configured and |
|
311 # the request does not use https, redirect to login form |
|
312 https_url = self.vreg.config['https-url'] |
|
313 if https_url and req.base_url() != https_url: |
|
314 req.status_out = http_client.SEE_OTHER |
|
315 req.headers_out.setHeader('location', https_url + 'login') |
|
316 else: |
|
317 # We assume here that in http auth mode the user *May* provide |
|
318 # Authentification Credential if asked kindly. |
|
319 if self.vreg.config['auth-mode'] == 'http': |
|
320 req.status_out = http_client.UNAUTHORIZED |
|
321 # In the other case (coky auth) we assume that there is no way |
|
322 # for the user to provide them... |
|
323 # XXX But WHY ? |
|
324 else: |
|
325 req.status_out = http_client.FORBIDDEN |
|
326 # If previous error handling already generated a custom content |
|
327 # do not overwrite it. This is used by LogOut Except |
|
328 # XXX ensure we don't actually serve content |
|
329 if not content: |
|
330 content = self.need_login_content(req) |
|
331 assert isinstance(content, binary_type) |
|
332 return content |
|
333 |
|
334 |
|
335 def core_handle(self, req, path): |
|
336 """method called by the main publisher to process <path> |
|
337 |
|
338 should return a string containing the resulting page or raise a |
|
339 `NotFound` exception |
|
340 |
|
341 :type path: str |
|
342 :param path: the path part of the url to publish |
|
343 |
|
344 :type req: `web.Request` |
|
345 :param req: the request object |
|
346 |
|
347 :rtype: str |
|
348 :return: the result of the pusblished url |
|
349 """ |
|
350 # don't log form values they may contains sensitive information |
|
351 self.debug('publish "%s" (%s, form params: %s)', |
|
352 path, req.session.sessionid, list(req.form)) |
|
353 # remove user callbacks on a new request (except for json controllers |
|
354 # to avoid callbacks being unregistered before they could be called) |
|
355 tstart = clock() |
|
356 commited = False |
|
357 try: |
|
358 ### standard processing of the request |
|
359 try: |
|
360 # apply CORS sanity checks |
|
361 cors.process_request(req, self.vreg.config) |
|
362 ctrlid, rset = self.url_resolver.process(req, path) |
|
363 try: |
|
364 controller = self.vreg['controllers'].select(ctrlid, req, |
|
365 appli=self) |
|
366 except NoSelectableObject: |
|
367 raise Unauthorized(req._('not authorized')) |
|
368 req.update_search_state() |
|
369 result = controller.publish(rset=rset) |
|
370 except cors.CORSPreflight: |
|
371 # Return directly an empty 200 |
|
372 req.status_out = 200 |
|
373 result = b'' |
|
374 except StatusResponse as ex: |
|
375 warn('[3.16] StatusResponse is deprecated use req.status_out', |
|
376 DeprecationWarning, stacklevel=2) |
|
377 result = ex.content |
|
378 req.status_out = ex.status |
|
379 except Redirect as ex: |
|
380 # Redirect may be raised by edit controller when everything went |
|
381 # fine, so attempt to commit |
|
382 result = self.redirect_handler(req, ex) |
|
383 if req.cnx: |
|
384 txuuid = req.cnx.commit() |
|
385 commited = True |
|
386 if txuuid is not None: |
|
387 req.data['last_undoable_transaction'] = txuuid |
|
388 ### error case |
|
389 except NotFound as ex: |
|
390 result = self.notfound_content(req) |
|
391 req.status_out = ex.status |
|
392 except ValidationError as ex: |
|
393 result = self.validation_error_handler(req, ex) |
|
394 except RemoteCallFailed as ex: |
|
395 result = self.ajax_error_handler(req, ex) |
|
396 except Unauthorized as ex: |
|
397 req.data['errmsg'] = req._('You\'re not authorized to access this page. ' |
|
398 'If you think you should, please contact the site administrator.') |
|
399 req.status_out = http_client.FORBIDDEN |
|
400 result = self.error_handler(req, ex, tb=False) |
|
401 except Forbidden as ex: |
|
402 req.data['errmsg'] = req._('This action is forbidden. ' |
|
403 'If you think it should be allowed, please contact the site administrator.') |
|
404 req.status_out = http_client.FORBIDDEN |
|
405 result = self.error_handler(req, ex, tb=False) |
|
406 except (BadRQLQuery, RequestError) as ex: |
|
407 result = self.error_handler(req, ex, tb=False) |
|
408 ### pass through exception |
|
409 except DirectResponse: |
|
410 if req.cnx: |
|
411 req.cnx.commit() |
|
412 raise |
|
413 except (AuthenticationError, LogOut): |
|
414 # the rollback is handled in the finally |
|
415 raise |
|
416 ### Last defense line |
|
417 except BaseException as ex: |
|
418 req.status_out = http_client.INTERNAL_SERVER_ERROR |
|
419 result = self.error_handler(req, ex, tb=True) |
|
420 finally: |
|
421 if req.cnx and not commited: |
|
422 try: |
|
423 req.cnx.rollback() |
|
424 except Exception: |
|
425 pass # ignore rollback error at this point |
|
426 self.add_undo_link_to_msg(req) |
|
427 self.debug('query %s executed in %s sec', req.relative_path(), clock() - tstart) |
|
428 return result |
|
429 |
|
430 # Error handlers |
|
431 |
|
432 def redirect_handler(self, req, ex): |
|
433 """handle redirect |
|
434 - comply to ex status |
|
435 - set header field |
|
436 - return empty content |
|
437 """ |
|
438 self.debug('redirecting to %s', str(ex.location)) |
|
439 req.headers_out.setHeader('location', str(ex.location)) |
|
440 assert 300 <= ex.status < 400 |
|
441 req.status_out = ex.status |
|
442 return b'' |
|
443 |
|
444 def validation_error_handler(self, req, ex): |
|
445 ex.translate(req._) # translate messages using ui language |
|
446 if '__errorurl' in req.form: |
|
447 forminfo = {'error': ex, |
|
448 'values': req.form, |
|
449 'eidmap': req.data.get('eidmap', {}) |
|
450 } |
|
451 req.session.data[req.form['__errorurl']] = forminfo |
|
452 # XXX form session key / __error_url should be differentiated: |
|
453 # session key is 'url + #<form dom id', though we usually don't want |
|
454 # the browser to move to the form since it hides the global |
|
455 # messages. |
|
456 location = req.form['__errorurl'].rsplit('#', 1)[0] |
|
457 req.headers_out.setHeader('location', str(location)) |
|
458 req.status_out = http_client.SEE_OTHER |
|
459 return b'' |
|
460 req.status_out = http_client.CONFLICT |
|
461 return self.error_handler(req, ex, tb=False) |
|
462 |
|
463 def error_handler(self, req, ex, tb=False): |
|
464 excinfo = sys.exc_info() |
|
465 if tb: |
|
466 self.exception(repr(ex)) |
|
467 req.set_header('Cache-Control', 'no-cache') |
|
468 req.remove_header('Etag') |
|
469 req.remove_header('Content-disposition') |
|
470 req.reset_message() |
|
471 req.reset_headers() |
|
472 if req.ajax_request: |
|
473 return self.ajax_error_handler(req, ex) |
|
474 try: |
|
475 req.data['ex'] = ex |
|
476 if tb: |
|
477 req.data['excinfo'] = excinfo |
|
478 errview = self.vreg['views'].select('error', req) |
|
479 template = self.main_template_id(req) |
|
480 content = self.vreg['views'].main_template(req, template, view=errview) |
|
481 except Exception: |
|
482 content = self.vreg['views'].main_template(req, 'error-template') |
|
483 if isinstance(ex, PublishException) and ex.status is not None: |
|
484 req.status_out = ex.status |
|
485 return content |
|
486 |
|
487 def add_undo_link_to_msg(self, req): |
|
488 txuuid = req.data.get('last_undoable_transaction') |
|
489 if txuuid is not None: |
|
490 msg = u'<span class="undo">[<a href="%s">%s</a>]</span>' %( |
|
491 req.build_url('undo', txuuid=txuuid), req._('undo')) |
|
492 req.append_to_redirect_message(msg) |
|
493 |
|
494 def ajax_error_handler(self, req, ex): |
|
495 req.set_header('content-type', 'application/json') |
|
496 status = http_client.INTERNAL_SERVER_ERROR |
|
497 if isinstance(ex, PublishException) and ex.status is not None: |
|
498 status = ex.status |
|
499 if req.status_out < 400: |
|
500 # don't overwrite it if it's already set |
|
501 req.status_out = status |
|
502 json_dumper = getattr(ex, 'dumps', lambda : json.dumps({'reason': text_type(ex)})) |
|
503 return json_dumper().encode('utf-8') |
|
504 |
|
505 # special case handling |
|
506 |
|
507 def need_login_content(self, req): |
|
508 return self.vreg['views'].main_template(req, 'login') |
|
509 |
|
510 def loggedout_content(self, req): |
|
511 return self.vreg['views'].main_template(req, 'loggedout') |
|
512 |
|
513 def notfound_content(self, req): |
|
514 req.form['vid'] = '404' |
|
515 view = self.vreg['views'].select('404', req) |
|
516 template = self.main_template_id(req) |
|
517 return self.vreg['views'].main_template(req, template, view=view) |
|
518 |
|
519 # template stuff |
|
520 |
|
521 def main_template_id(self, req): |
|
522 template = req.form.get('__template', req.property_value('ui.main-template')) |
|
523 if template not in self.vreg['views']: |
|
524 template = 'main-template' |
|
525 return template |
|
526 |
|
527 # these are overridden by set_log_methods below |
|
528 # only defining here to prevent pylint from complaining |
|
529 info = warning = error = critical = exception = debug = lambda msg,*a,**kw: None |
|
530 |
|
531 set_log_methods(CubicWebPublisher, LOGGER) |
|
532 set_log_methods(CookieSessionHandler, LOGGER) |