[FIX] Inject user context in all domain and context evaluation
[odoo/odoo.git] / addons / web / http.py
1 # -*- coding: utf-8 -*-
2 #----------------------------------------------------------
3 # OpenERP Web HTTP layer
4 #----------------------------------------------------------
5 import ast
6 import cgi
7 import contextlib
8 import functools
9 import getpass
10 import logging
11 import mimetypes
12 import os
13 import pprint
14 import random
15 import sys
16 import tempfile
17 import threading
18 import time
19 import traceback
20 import urlparse
21 import uuid
22 import xmlrpclib
23
24 import babel.core
25 import simplejson
26 import werkzeug.contrib.sessions
27 import werkzeug.datastructures
28 import werkzeug.exceptions
29 import werkzeug.utils
30 import werkzeug.wrappers
31 import werkzeug.wsgi
32
33 import openerp
34
35 import session
36
37 _logger = logging.getLogger(__name__)
38
39 #----------------------------------------------------------
40 # RequestHandler
41 #----------------------------------------------------------
42 class WebRequest(object):
43     """ Parent class for all OpenERP Web request types, mostly deals with
44     initialization and setup of the request object (the dispatching itself has
45     to be handled by the subclasses)
46
47     :param request: a wrapped werkzeug Request object
48     :type request: :class:`werkzeug.wrappers.BaseRequest`
49
50     .. attribute:: httprequest
51
52         the original :class:`werkzeug.wrappers.Request` object provided to the
53         request
54
55     .. attribute:: httpsession
56
57         a :class:`~collections.Mapping` holding the HTTP session data for the
58         current http session
59
60     .. attribute:: params
61
62         :class:`~collections.Mapping` of request parameters, not generally
63         useful as they're provided directly to the handler method as keyword
64         arguments
65
66     .. attribute:: session_id
67
68         opaque identifier for the :class:`session.OpenERPSession` instance of
69         the current request
70
71     .. attribute:: session
72
73         :class:`~session.OpenERPSession` instance for the current request
74
75     .. attribute:: context
76
77         :class:`~collections.Mapping` of context values for the current request
78
79     .. attribute:: debug
80
81         ``bool``, indicates whether the debug mode is active on the client
82     """
83     def __init__(self, request):
84         self.httprequest = request
85         self.httpresponse = None
86         self.httpsession = request.session
87
88     def init(self, params):
89         self.params = dict(params)
90         # OpenERP session setup
91         self.session_id = self.params.pop("session_id", None) or uuid.uuid4().hex
92         self.session = self.httpsession.get(self.session_id)
93         if not self.session:
94             self.session = session.OpenERPSession()
95             self.httpsession[self.session_id] = self.session
96         self.context = self.params.pop('context', {})
97         self.debug = self.params.pop('debug', False) is not False
98         # Determine self.lang
99         lang = self.params.get('lang', None)
100         if lang is None:
101             lang = self.context.get('lang')
102         if lang is None:
103             lang = self.httprequest.cookies.get('lang')
104         if lang is None:
105             lang = self.httprequest.accept_languages.best
106         if not lang:
107             lang = 'en_US'
108         # tranform 2 letters lang like 'en' into 5 letters like 'en_US'
109         lang = babel.core.LOCALE_ALIASES.get(lang, lang)
110         # we use _ as seprator where RFC2616 uses '-'
111         self.lang = lang.replace('-', '_')
112
113 def reject_nonliteral(dct):
114     if '__ref' in dct:
115         raise ValueError(
116             "Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
117     return dct
118
119 class JsonRequest(WebRequest):
120     """ JSON-RPC2 over HTTP.
121
122     Sucessful request::
123
124       --> {"jsonrpc": "2.0",
125            "method": "call",
126            "params": {"session_id": "SID",
127                       "context": {},
128                       "arg1": "val1" },
129            "id": null}
130
131       <-- {"jsonrpc": "2.0",
132            "result": { "res1": "val1" },
133            "id": null}
134
135     Request producing a error::
136
137       --> {"jsonrpc": "2.0",
138            "method": "call",
139            "params": {"session_id": "SID",
140                       "context": {},
141                       "arg1": "val1" },
142            "id": null}
143
144       <-- {"jsonrpc": "2.0",
145            "error": {"code": 1,
146                      "message": "End user error message.",
147                      "data": {"code": "codestring",
148                               "debug": "traceback" } },
149            "id": null}
150
151     """
152     def dispatch(self, method):
153         """ Calls the method asked for by the JSON-RPC2 or JSONP request
154
155         :param method: the method which received the request
156
157         :returns: an utf8 encoded JSON-RPC2 or JSONP reply
158         """
159         args = self.httprequest.args
160         jsonp = args.get('jsonp')
161         requestf = None
162         request = None
163         request_id = args.get('id')
164
165         if jsonp and self.httprequest.method == 'POST':
166             # jsonp 2 steps step1 POST: save call
167             self.init(args)
168             self.session.jsonp_requests[request_id] = self.httprequest.form['r']
169             headers=[('Content-Type', 'text/plain; charset=utf-8')]
170             r = werkzeug.wrappers.Response(request_id, headers=headers)
171             return r
172         elif jsonp and args.get('r'):
173             # jsonp method GET
174             request = args.get('r')
175         elif jsonp and request_id:
176             # jsonp 2 steps step2 GET: run and return result
177             self.init(args)
178             request = self.session.jsonp_requests.pop(request_id, "")
179         else:
180             # regular jsonrpc2
181             requestf = self.httprequest.stream
182
183         response = {"jsonrpc": "2.0" }
184         error = None
185         try:
186             # Read POST content or POST Form Data named "request"
187             if requestf:
188                 self.jsonrequest = simplejson.load(requestf, object_hook=reject_nonliteral)
189             else:
190                 self.jsonrequest = simplejson.loads(request, object_hook=reject_nonliteral)
191             self.init(self.jsonrequest.get("params", {}))
192             if _logger.isEnabledFor(logging.DEBUG):
193                 _logger.debug("--> %s.%s\n%s", method.im_class.__name__, method.__name__, pprint.pformat(self.jsonrequest))
194             response['id'] = self.jsonrequest.get('id')
195             response["result"] = method(self, **self.params)
196         except session.AuthenticationError:
197             error = {
198                 'code': 100,
199                 'message': "OpenERP Session Invalid",
200                 'data': {
201                     'type': 'session_invalid',
202                     'debug': traceback.format_exc()
203                 }
204             }
205         except xmlrpclib.Fault, e:
206             error = {
207                 'code': 200,
208                 'message': "OpenERP Server Error",
209                 'data': {
210                     'type': 'server_exception',
211                     'fault_code': e.faultCode,
212                     'debug': "Client %s\nServer %s" % (
213                     "".join(traceback.format_exception("", None, sys.exc_traceback)), e.faultString)
214                 }
215             }
216         except Exception:
217             logging.getLogger(__name__ + '.JSONRequest.dispatch').exception\
218                 ("An error occured while handling a json request")
219             error = {
220                 'code': 300,
221                 'message': "OpenERP WebClient Error",
222                 'data': {
223                     'type': 'client_exception',
224                     'debug': "Client %s" % traceback.format_exc()
225                 }
226             }
227         if error:
228             response["error"] = error
229
230         if _logger.isEnabledFor(logging.DEBUG):
231             _logger.debug("<--\n%s", pprint.pformat(response))
232
233         if jsonp:
234             # If we use jsonp, that's mean we are called from another host
235             # Some browser (IE and Safari) do no allow third party cookies
236             # We need then to manage http sessions manually.
237             response['httpsessionid'] = self.httpsession.sid
238             mime = 'application/javascript'
239             body = "%s(%s);" % (jsonp, simplejson.dumps(response),)
240         else:
241             mime = 'application/json'
242             body = simplejson.dumps(response)
243
244         r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
245         return r
246
247 def jsonrequest(f):
248     """ Decorator marking the decorated method as being a handler for a
249     JSON-RPC request (the exact request path is specified via the
250     ``$(Controller._cp_path)/$methodname`` combination.
251
252     If the method is called, it will be provided with a :class:`JsonRequest`
253     instance and all ``params`` sent during the JSON-RPC request, apart from
254     the ``session_id``, ``context`` and ``debug`` keys (which are stripped out
255     beforehand)
256     """
257     f.exposed = 'json'
258     return f
259
260 class HttpRequest(WebRequest):
261     """ Regular GET/POST request
262     """
263     def dispatch(self, method):
264         params = dict(self.httprequest.args)
265         params.update(self.httprequest.form)
266         params.update(self.httprequest.files)
267         self.init(params)
268         akw = {}
269         for key, value in self.httprequest.args.iteritems():
270             if isinstance(value, basestring) and len(value) < 1024:
271                 akw[key] = value
272             else:
273                 akw[key] = type(value)
274         _logger.debug("%s --> %s.%s %r", self.httprequest.method, method.im_class.__name__, method.__name__, akw)
275         try:
276             r = method(self, **self.params)
277         except xmlrpclib.Fault, e:
278             r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
279                 'code': 200,
280                 'message': "OpenERP Server Error",
281                 'data': {
282                     'type': 'server_exception',
283                     'fault_code': e.faultCode,
284                     'debug': "Server %s\nClient %s" % (
285                         e.faultString, traceback.format_exc())
286                 }
287             })))
288         except Exception:
289             logging.getLogger(__name__ + '.HttpRequest.dispatch').exception(
290                     "An error occurred while handling a json request")
291             r = werkzeug.exceptions.InternalServerError(cgi.escape(simplejson.dumps({
292                 'code': 300,
293                 'message': "OpenERP WebClient Error",
294                 'data': {
295                     'type': 'client_exception',
296                     'debug': "Client %s" % traceback.format_exc()
297                 }
298             })))
299         if self.debug or 1:
300             if isinstance(r, (werkzeug.wrappers.BaseResponse, werkzeug.exceptions.HTTPException)):
301                 _logger.debug('<-- %s', r)
302             else:
303                 _logger.debug("<-- size: %s", len(r))
304         return r
305
306     def make_response(self, data, headers=None, cookies=None):
307         """ Helper for non-HTML responses, or HTML responses with custom
308         response headers or cookies.
309
310         While handlers can just return the HTML markup of a page they want to
311         send as a string if non-HTML data is returned they need to create a
312         complete response object, or the returned data will not be correctly
313         interpreted by the clients.
314
315         :param basestring data: response body
316         :param headers: HTTP headers to set on the response
317         :type headers: ``[(name, value)]``
318         :param collections.Mapping cookies: cookies to set on the client
319         """
320         response = werkzeug.wrappers.Response(data, headers=headers)
321         if cookies:
322             for k, v in cookies.iteritems():
323                 response.set_cookie(k, v)
324         return response
325
326     def not_found(self, description=None):
327         """ Helper for 404 response, return its result from the method
328         """
329         return werkzeug.exceptions.NotFound(description)
330
331 def httprequest(f):
332     """ Decorator marking the decorated method as being a handler for a
333     normal HTTP request (the exact request path is specified via the
334     ``$(Controller._cp_path)/$methodname`` combination.
335
336     If the method is called, it will be provided with a :class:`HttpRequest`
337     instance and all ``params`` sent during the request (``GET`` and ``POST``
338     merged in the same dictionary), apart from the ``session_id``, ``context``
339     and ``debug`` keys (which are stripped out beforehand)
340     """
341     f.exposed = 'http'
342     return f
343
344 #----------------------------------------------------------
345 # Controller registration with a metaclass
346 #----------------------------------------------------------
347 addons_module = {}
348 addons_manifest = {}
349 controllers_class = []
350 controllers_object = {}
351 controllers_path = {}
352
353 class ControllerType(type):
354     def __init__(cls, name, bases, attrs):
355         super(ControllerType, cls).__init__(name, bases, attrs)
356         controllers_class.append(("%s.%s" % (cls.__module__, cls.__name__), cls))
357
358 class Controller(object):
359     __metaclass__ = ControllerType
360
361 #----------------------------------------------------------
362 # Session context manager
363 #----------------------------------------------------------
364 @contextlib.contextmanager
365 def session_context(request, session_store, session_lock, sid):
366     with session_lock:
367         if sid:
368             request.session = session_store.get(sid)
369         else:
370             request.session = session_store.new()
371     try:
372         yield request.session
373     finally:
374         # Remove all OpenERPSession instances with no uid, they're generated
375         # either by login process or by HTTP requests without an OpenERP
376         # session id, and are generally noise
377         removed_sessions = set()
378         for key, value in request.session.items():
379             if not isinstance(value, session.OpenERPSession):
380                 continue
381             if getattr(value, '_suicide', False) or (
382                         not value._uid
383                     and not value.jsonp_requests
384                     # FIXME do not use a fixed value
385                     and value._creation_time + (60*5) < time.time()):
386                 _logger.debug('remove session %s', key)
387                 removed_sessions.add(key)
388                 del request.session[key]
389
390         with session_lock:
391             if sid:
392                 # Re-load sessions from storage and merge non-literal
393                 # contexts and domains (they're indexed by hash of the
394                 # content so conflicts should auto-resolve), otherwise if
395                 # two requests alter those concurrently the last to finish
396                 # will overwrite the previous one, leading to loss of data
397                 # (a non-literal is lost even though it was sent to the
398                 # client and client errors)
399                 #
400                 # note that domains_store and contexts_store are append-only (we
401                 # only ever add items to them), so we can just update one with the
402                 # other to get the right result, if we want to merge the
403                 # ``context`` dict we'll need something smarter
404                 in_store = session_store.get(sid)
405                 for k, v in request.session.iteritems():
406                     stored = in_store.get(k)
407                     if stored and isinstance(v, session.OpenERPSession):
408                         if hasattr(v, 'contexts_store'):
409                             del v.contexts_store
410                         if hasattr(v, 'domains_store'):
411                             del v.domains_store
412                         if not hasattr(v, 'jsonp_requests'):
413                             v.jsonp_requests = {}
414                         v.jsonp_requests.update(getattr(
415                             stored, 'jsonp_requests', {}))
416
417                 # add missing keys
418                 for k, v in in_store.iteritems():
419                     if k not in request.session and k not in removed_sessions:
420                         request.session[k] = v
421
422             session_store.save(request.session)
423
424 def session_gc(session_store):
425     if random.random() < 0.001:
426         # we keep session one week
427         last_week = time.time() - 60*60*24*7
428         for fname in os.listdir(session_store.path):
429             path = os.path.join(session_store.path, fname)
430             try:
431                 if os.path.getmtime(path) < last_week:
432                     os.unlink(path)
433             except OSError:
434                 pass
435
436 #----------------------------------------------------------
437 # WSGI Application
438 #----------------------------------------------------------
439 # Add potentially missing (older ubuntu) font mime types
440 mimetypes.add_type('application/font-woff', '.woff')
441 mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
442 mimetypes.add_type('application/x-font-ttf', '.ttf')
443
444 class DisableCacheMiddleware(object):
445     def __init__(self, app):
446         self.app = app
447     def __call__(self, environ, start_response):
448         def start_wrapped(status, headers):
449             referer = environ.get('HTTP_REFERER', '')
450             parsed = urlparse.urlparse(referer)
451             debug = parsed.query.count('debug') >= 1
452
453             new_headers = []
454             unwanted_keys = ['Last-Modified']
455             if debug:
456                 new_headers = [('Cache-Control', 'no-cache')]
457                 unwanted_keys += ['Expires', 'Etag', 'Cache-Control']
458
459             for k, v in headers:
460                 if k not in unwanted_keys:
461                     new_headers.append((k, v))
462
463             start_response(status, new_headers)
464         return self.app(environ, start_wrapped)
465
466 def session_path():
467     try:
468         username = getpass.getuser()
469     except Exception:
470         username = "unknown"
471     path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
472     if not os.path.exists(path):
473         os.mkdir(path, 0700)
474     return path
475
476 class Root(object):
477     """Root WSGI application for the OpenERP Web Client.
478     """
479     def __init__(self):
480         self.addons = {}
481         self.statics = {}
482
483         self.load_addons()
484
485         # Setup http sessions
486         path = session_path()
487         self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
488         self.session_lock = threading.Lock()
489         _logger.debug('HTTP sessions stored in: %s', path)
490
491     def __call__(self, environ, start_response):
492         """ Handle a WSGI request
493         """
494         return self.dispatch(environ, start_response)
495
496     def dispatch(self, environ, start_response):
497         """
498         Performs the actual WSGI dispatching for the application, may be
499         wrapped during the initialization of the object.
500
501         Call the object directly.
502         """
503         request = werkzeug.wrappers.Request(environ)
504         request.parameter_storage_class = werkzeug.datastructures.ImmutableDict
505         request.app = self
506
507         handler = self.find_handler(*(request.path.split('/')[1:]))
508
509         if not handler:
510             response = werkzeug.exceptions.NotFound()
511         else:
512             sid = request.cookies.get('sid')
513             if not sid:
514                 sid = request.args.get('sid')
515
516             session_gc(self.session_store)
517
518             with session_context(request, self.session_store, self.session_lock, sid) as session:
519                 result = handler(request)
520
521                 if isinstance(result, basestring):
522                     headers=[('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', len(result))]
523                     response = werkzeug.wrappers.Response(result, headers=headers)
524                 else:
525                     response = result
526
527                 if hasattr(response, 'set_cookie'):
528                     response.set_cookie('sid', session.sid)
529
530         return response(environ, start_response)
531
532     def load_addons(self):
533         """ Load all addons from addons patch containg static files and
534         controllers and configure them.  """
535
536         for addons_path in openerp.modules.module.ad_paths:
537             for module in sorted(os.listdir(addons_path)):
538                 if module not in addons_module:
539                     manifest_path = os.path.join(addons_path, module, '__openerp__.py')
540                     path_static = os.path.join(addons_path, module, 'static')
541                     if os.path.isfile(manifest_path) and os.path.isdir(path_static):
542                         manifest = ast.literal_eval(open(manifest_path).read())
543                         manifest['addons_path'] = addons_path
544                         _logger.debug("Loading %s", module)
545                         if 'openerp.addons' in sys.modules:
546                             m = __import__('openerp.addons.' + module)
547                         else:
548                             m = __import__(module)
549                         addons_module[module] = m
550                         addons_manifest[module] = manifest
551                         self.statics['/%s/static' % module] = path_static
552
553         for k, v in controllers_class:
554             if k not in controllers_object:
555                 o = v()
556                 controllers_object[k] = o
557                 if hasattr(o, '_cp_path'):
558                     controllers_path[o._cp_path] = o
559
560         app = werkzeug.wsgi.SharedDataMiddleware(self.dispatch, self.statics)
561         self.dispatch = DisableCacheMiddleware(app)
562
563     def find_handler(self, *l):
564         """
565         Tries to discover the controller handling the request for the path
566         specified by the provided parameters
567
568         :param l: path sections to a controller or controller method
569         :returns: a callable matching the path sections, or ``None``
570         :rtype: ``Controller | None``
571         """
572         if l:
573             ps = '/' + '/'.join(filter(None, l))
574             method_name = 'index'
575             while ps:
576                 c = controllers_path.get(ps)
577                 if c:
578                     method = getattr(c, method_name, None)
579                     if method:
580                         exposed = getattr(method, 'exposed', False)
581                         if exposed == 'json':
582                             _logger.debug("Dispatch json to %s %s %s", ps, c, method_name)
583                             return lambda request: JsonRequest(request).dispatch(method)
584                         elif exposed == 'http':
585                             _logger.debug("Dispatch http to %s %s %s", ps, c, method_name)
586                             return lambda request: HttpRequest(request).dispatch(method)
587                 ps, _slash, method_name = ps.rpartition('/')
588                 if not ps and method_name:
589                     ps = '/'
590         return None
591
592 def wsgi_postload():
593     openerp.wsgi.register_wsgi_handler(Root())
594
595 # vim:et:ts=4:sw=4: