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