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