Coverage for adhoc-cicd-odoo-odoo / odoo / http.py: 26%
1402 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:15 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:15 +0000
1# Part of Odoo. See LICENSE file for full copyright and licensing details.
2r"""\
3Odoo HTTP layer / WSGI application
5The main duty of this module is to prepare and dispatch all http
6requests to their corresponding controllers: from a raw http request
7arriving on the WSGI entrypoint to a :class:`~http.Request`: arriving at
8a module controller with a fully setup ORM available.
10Application developers mostly know this module thanks to the
11:class:`~odoo.http.Controller`: class and its companion the
12:func:`~odoo.http.route`: method decorator. Together they are used to
13register methods responsible of delivering web content to matching URLS.
15Those two are only the tip of the iceberg, below is a call graph that
16shows the various processing layers each request passes through before
17ending at the @route decorated endpoint. Hopefully, this call graph and
18the attached function descriptions will help you understand this module.
20Here be dragons:
22 Application.__call__
23 if path is like '/<module>/static/<path>':
24 Request._serve_static
26 elif not request.db:
27 Request._serve_nodb
28 App.nodb_routing_map.match
29 Dispatcher.pre_dispatch
30 Dispatcher.dispatch
31 route_wrapper
32 endpoint
33 Dispatcher.post_dispatch
35 else:
36 Request._serve_db
37 env['ir.http']._match
38 if not match:
39 model.retrying(Request._serve_ir_http_fallback)
40 env['ir.http']._serve_fallback
41 env['ir.http']._post_dispatch
42 else:
43 model.retrying(Request._serve_ir_http)
44 env['ir.http']._authenticate
45 env['ir.http']._pre_dispatch
46 Dispatcher.pre_dispatch
47 Dispatcher.dispatch
48 env['ir.http']._dispatch
49 route_wrapper
50 endpoint
51 env['ir.http']._post_dispatch
53Application.__call__
54 WSGI entry point, it sanitizes the request, it wraps it in a werkzeug
55 request and itself in an Odoo http request. The Odoo http request is
56 exposed at ``http.request`` then it is forwarded to either
57 ``_serve_static``, ``_serve_nodb`` or ``_serve_db`` depending on the
58 request path and the presence of a database. It is also responsible of
59 ensuring any error is properly logged and encapsuled in a HTTP error
60 response.
62Request._serve_static
63 Handle all requests to ``/<module>/static/<asset>`` paths, open the
64 underlying file on the filesystem and stream it via
65 :meth:``Request.send_file``
67Request._serve_nodb
68 Handle requests to ``@route(auth='none')`` endpoints when the user is
69 not connected to a database. It performs limited operations, just
70 matching the auth='none' endpoint using the request path and then it
71 delegates to Dispatcher.
73Request._serve_db
74 Handle all requests that are not static when it is possible to connect
75 to a database. It opens a registry on the database, manage the request
76 cursor and environment. The function decides whether to use a
77 read-only or a read/write cursor for its operations:
78 ``check_signaling``, ``match`` and ``serve_fallback`` are called using
79 the same read-only cursor; ``_serve_ir_http`` is called reusing the
80 same (but reset) read-only cursor, or a new read/write one.
82service.model.retrying
83 Manage the cursor, the environment and exceptions that occured while
84 executing the underlying function. They recover from various
85 exceptions such as serialization errors and writes in read-only
86 transactions. They catches all other exceptions and attach a http
87 response to them (e.g. 500 - Internal Server Error)
89ir.http._match
90 Match the controller endpoint that correspond to the request path.
91 Beware that there is an important override for portal and website
92 inside of the ``http_routing`` module.
94ir.http._serve_fallback
95 Find alternative ways to serve a request when its path does not match
96 any controller. The path could be matching an attachment URL, a blog
97 page, etc.
99ir.http._authenticate
100 Ensure the user on the current environment fulfill the requirement of
101 ``@route(auth=...)``. Using the ORM outside of abstract models is
102 unsafe prior of calling this function.
104ir.http._pre_dispatch/Dispatcher.pre_dispatch
105 Prepare the system the handle the current request, often used to save
106 some extra query-string parameters in the session (e.g. ?debug=1)
108ir.http._dispatch/Dispatcher.dispatch
109 Deserialize the HTTP request body into ``request.params`` according to
110 @route(type=...), call the controller endpoint, serialize its return
111 value into an HTTP Response object.
113ir.http._post_dispatch/Dispatcher.post_dispatch
114 Post process the response returned by the controller endpoint. Used to
115 inject various headers such as Content-Security-Policy.
117ir.http._handle_error
118 Not present in the call-graph, is called for un-managed exceptions (SE
119 or RO) that occured inside of ``Request._retrying``. It returns a http
120 response that wraps the error that occured.
122route_wrapper, closure of the http.route decorator
123 Sanitize the request parameters, call the route endpoint and
124 optionally coerce the endpoint result.
126endpoint
127 The @route(...) decorated controller method.
128"""
130import odoo.init # import first for core setup
132import base64
133import collections.abc
134import contextlib
135import functools
136import glob
137import hashlib
138import hmac
139import importlib.metadata
140import inspect
141import json
142import logging
143import mimetypes
144import os
145import re
146import threading
147import time
148import traceback
149import warnings
150from abc import ABC, abstractmethod
151from datetime import datetime, timedelta
152from hashlib import sha512
153from http import HTTPStatus
154from io import BytesIO
155from os.path import join as opj
156from pathlib import Path
157from urllib.parse import urlparse
158from zlib import adler32
160import babel.core
162try:
163 import geoip2.database
164 import geoip2.models
165 import geoip2.errors
166except ImportError:
167 geoip2 = None
169try:
170 import maxminddb
171except ImportError:
172 maxminddb = None
174import psycopg2
175import werkzeug.datastructures
176import werkzeug.exceptions
177import werkzeug.local
178import werkzeug.routing
179import werkzeug.security
180import werkzeug.wrappers
181import werkzeug.wsgi
182from werkzeug.urls import URL, url_parse, url_encode, url_quote
183from werkzeug.exceptions import (
184 default_exceptions as werkzeug_default_exceptions,
185 HTTPException, NotFound, UnsupportedMediaType, UnprocessableEntity,
186 InternalServerError
187)
188try:
189 from werkzeug.middleware.proxy_fix import ProxyFix as ProxyFix_
190 ProxyFix = functools.partial(ProxyFix_, x_for=1, x_proto=1, x_host=1)
191except ImportError:
192 from werkzeug.contrib.fixers import ProxyFix
194try:
195 from werkzeug.utils import send_file as _send_file
196except ImportError:
197 from .tools._vendor.send_file import send_file as _send_file
199import odoo.addons
200from .exceptions import UserError, AccessError, AccessDenied
201from .modules import module as module_manager
202from .modules.registry import Registry
203from .service import security, model as service_model
204from .service.server import thread_local
205from .tools import (config, consteq, file_path, get_lang, json_default,
206 parse_version, profiler, unique, exception_to_unicode)
207from .tools.facade import Proxy, ProxyAttr, ProxyFunc
208from .tools.func import filter_kwargs
209from .tools.misc import submap, real_time
210from .tools._vendor import sessions
211from .tools._vendor.useragents import UserAgent
214_logger = logging.getLogger(__name__)
217# =========================================================
218# Const
219# =========================================================
221# The validity duration of a preflight response, one day.
222CORS_MAX_AGE = 60 * 60 * 24
224# The HTTP methods that do not require a CSRF validation.
225SAFE_HTTP_METHODS = ('GET', 'HEAD', 'OPTIONS', 'TRACE')
227# The default csrf token lifetime, a salt against BREACH, one year
228CSRF_TOKEN_SALT = 60 * 60 * 24 * 365
230# The default lang to use when the browser doesn't specify it
231DEFAULT_LANG = 'en_US'
233# The dictionary to initialise a new session with.
234def get_default_session():
235 return {
236 'context': {}, # 'lang': request.default_lang() # must be set at runtime
237 'create_time': time.time(),
238 'db': None,
239 'debug': '',
240 'login': None,
241 'uid': None,
242 'session_token': None,
243 '_trace': [],
244 'create_time': time.time(),
245 }
247DEFAULT_MAX_CONTENT_LENGTH = 128 * 1024 * 1024 # 128MiB
249# Two empty objects used when the geolocalization failed. They have the
250# sames attributes as real countries/cities except that accessing them
251# evaluates to None.
252if geoip2: 252 ↛ 256line 252 didn't jump to line 256 because the condition on line 252 was always true
253 GEOIP_EMPTY_COUNTRY = geoip2.models.Country({})
254 GEOIP_EMPTY_CITY = geoip2.models.City({})
256MISSING_CSRF_WARNING = """\
257No CSRF validation token provided for path %r
259Odoo URLs are CSRF-protected by default (when accessed with unsafe
260HTTP methods). See
261https://www.odoo.com/documentation/master/developer/reference/addons/http.html#csrf
262for more details.
264* if this endpoint is accessed through Odoo via py-QWeb form, embed a CSRF
265 token in the form, Tokens are available via `request.csrf_token()`
266 can be provided through a hidden input and must be POST-ed named
267 `csrf_token` e.g. in your form add:
268 <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
270* if the form is generated or posted in javascript, the token value is
271 available as `csrf_token` on `web.core` and as the `csrf_token`
272 value in the default js-qweb execution context
274* if the form is accessed by an external third party (e.g. REST API
275 endpoint, payment gateway callback) you will need to disable CSRF
276 protection (and implement your own protection if necessary) by
277 passing the `csrf=False` parameter to the `route` decorator.
278"""
280NOT_FOUND_NODB = """\
281<!DOCTYPE html>
282<title>404 Not Found</title>
283<h1>Not Found</h1>
284<p>No database is selected and the requested URL was not found in the server-wide controllers.</p>
285<p>Please verify the hostname, <a href=/web/login>login</a> and try again.</p>
287<!-- Alternatively, use the X-Odoo-Database header. -->
288"""
290# The @route arguments to propagate from the decorated method to the
291# routing rule.
292ROUTING_KEYS = {
293 'defaults', 'subdomain', 'build_only', 'strict_slashes', 'redirect_to',
294 'alias', 'host', 'methods',
295}
297if parse_version(importlib.metadata.version('werkzeug')) >= parse_version('2.0.2'): 297 ↛ 308line 297 didn't jump to line 308 because the condition on line 297 was always true
298 # Werkzeug 2.0.2 adds the websocket option. If a websocket request
299 # (ws/wss) is trying to access an HTTP route, a WebsocketMismatch
300 # exception is raised. On the other hand, Werkzeug 0.16 does not
301 # support the websocket routing key. In order to bypass this issue,
302 # let's add the websocket key only when appropriate.
303 ROUTING_KEYS.add('websocket')
305# The default duration of a user session cookie. Inactive sessions are reaped
306# server-side as well with a threshold that can be set via an optional
307# config parameter `sessions.max_inactivity_seconds` (default: SESSION_LIFETIME)
308SESSION_LIFETIME = 60 * 60 * 24 * 7
310# The default duration (3h) before a session is rotated, changing the
311# session id (also on the cookie) but keeping the same content.
312SESSION_ROTATION_INTERVAL = 60 * 60 * 3
314# URL paths for which automatic session rotation is disabled.
315SESSION_ROTATION_EXCLUDED_PATHS = (
316 '/websocket/peek_notifications',
317 '/websocket/update_bus_presence',
318)
320# After a session is rotated, the session should be kept for a couple of
321# seconds to account for network delay between multiple requests which are
322# made at the same time and all use the same old cookie.
323SESSION_DELETION_TIMER = 120
325# The amount of bytes of the session that will remain static and can be used
326# for calculating the csrf token and be stored inside the database.
327STORED_SESSION_BYTES = 42
329# The cache duration for static content from the filesystem, one week.
330STATIC_CACHE = 60 * 60 * 24 * 7
332# The cache duration for content where the url uniquely identifies the
333# content (usually using a hash), one year.
334STATIC_CACHE_LONG = 60 * 60 * 24 * 365
337# =========================================================
338# Helpers
339# =========================================================
341class RegistryError(RuntimeError):
342 pass
345class SessionExpiredException(Exception):
346 http_status = HTTPStatus.FORBIDDEN
349def content_disposition(filename, disposition_type='attachment'):
350 """
351 Craft a ``Content-Disposition`` header, see :rfc:`6266`.
353 :param filename: The name of the file, should that file be saved on
354 disk by the browser.
355 :param disposition_type: Tell the browser what to do with the file,
356 either ``"attachment"`` to save the file on disk,
357 either ``"inline"`` to display the file.
358 """
359 if disposition_type not in ('attachment', 'inline'):
360 e = f"Invalid disposition_type: {disposition_type!r}"
361 raise ValueError(e)
362 return "{}; filename*=UTF-8''{}".format(
363 disposition_type,
364 url_quote(filename, safe='', unsafe='()<>@,;:"/[]?={}\\*\'%') # RFC6266
365 )
368def db_list(force=False, host=None):
369 """
370 Get the list of available databases.
372 :param bool force: See :func:`~odoo.service.db.list_dbs`:
373 :param host: The Host used to replace %h and %d in the dbfilters
374 regexp. Taken from the current request when omitted.
375 :returns: the list of available databases
376 :rtype: List[str]
377 """
378 try:
379 dbs = odoo.service.db.list_dbs(force)
380 except psycopg2.OperationalError:
381 return []
382 return db_filter(dbs, host)
385def db_filter(dbs, host=None):
386 """
387 Return the subset of ``dbs`` that match the dbfilter or the dbname
388 server configuration. In case neither are configured, return ``dbs``
389 as-is.
391 :param Iterable[str] dbs: The list of database names to filter.
392 :param host: The Host used to replace %h and %d in the dbfilters
393 regexp. Taken from the current request when omitted.
394 :returns: The original list filtered.
395 :rtype: List[str]
396 """
398 if config['dbfilter']:
399 # host
400 # -----------
401 # www.example.com:80
402 # -------
403 # domain
404 if host is None:
405 host = request.httprequest.environ.get('HTTP_HOST', '')
406 host = host.partition(':')[0]
407 if host.startswith('www.'):
408 host = host[4:]
409 domain = host.partition('.')[0]
411 dbfilter_re = re.compile(
412 config["dbfilter"].replace("%h", re.escape(host))
413 .replace("%d", re.escape(domain)))
414 return [db for db in dbs if dbfilter_re.match(db)]
416 if config['db_name']:
417 # In case --db-filter is not provided and --database is passed, Odoo will
418 # use the value of --database as a comma separated list of exposed databases.
419 return sorted(set(config['db_name']).intersection(dbs))
421 return list(dbs)
424def dispatch_rpc(service_name, method, params):
425 """
426 Perform a RPC call.
428 :param str service_name: either "common", "db" or "object".
429 :param str method: the method name of the given service to execute
430 :param Mapping params: the keyword arguments for method call
431 :return: the return value of the called method
432 :rtype: Any
433 """
434 rpc_dispatchers = {
435 'common': odoo.service.common.dispatch,
436 'db': odoo.service.db.dispatch,
437 'object': odoo.service.model.dispatch,
438 }
440 with borrow_request():
441 threading.current_thread().uid = None
442 threading.current_thread().dbname = None
444 dispatch = rpc_dispatchers[service_name]
445 return dispatch(method, params)
448def get_session_max_inactivity(env):
449 if not env or env.cr._closed:
450 return SESSION_LIFETIME
452 ICP = env['ir.config_parameter'].sudo()
454 try:
455 return int(ICP.get_param('sessions.max_inactivity_seconds', SESSION_LIFETIME))
456 except ValueError:
457 _logger.warning("Invalid value for 'sessions.max_inactivity_seconds', using default value.")
458 return SESSION_LIFETIME
461def is_cors_preflight(request, endpoint):
462 return request.httprequest.method == 'OPTIONS' and endpoint.routing.get('cors', False)
465def serialize_exception(exception, *, message=None, arguments=None):
466 name = type(exception).__name__
467 module = type(exception).__module__
469 return {
470 'name': f'{module}.{name}' if module else name,
471 'message': exception_to_unicode(exception) if message is None else message,
472 'arguments': exception.args if arguments is None else arguments,
473 'context': getattr(exception, 'context', {}),
474 'debug': ''.join(traceback.format_exception(exception)),
475 }
478# =========================================================
479# File Streaming
480# =========================================================
483class Stream:
484 """
485 Send the content of a file, an attachment or a binary field via HTTP
487 This utility is safe, cache-aware and uses the best available
488 streaming strategy. Works best with the --x-sendfile cli option.
490 Create a Stream via one of the constructors: :meth:`~from_path`:, or
491 :meth:`~from_binary_field`:, generate the corresponding HTTP response
492 object via :meth:`~get_response`:.
494 Instantiating a Stream object manually without using one of the
495 dedicated constructors is discouraged.
496 """
498 type: str = '' # 'data' or 'path' or 'url'
499 data = None
500 path = None
501 url = None
503 mimetype = None
504 as_attachment = False
505 download_name = None
506 conditional = True
507 etag = True
508 last_modified = None
509 max_age = None
510 immutable = False
511 size = None
512 public = False
514 def __init__(self, **kwargs):
515 # Remove class methods from the instances
516 self.from_path = self.from_attachment = self.from_binary_field = None
517 self.__dict__.update(kwargs)
519 @classmethod
520 def from_path(cls, path, filter_ext=('',), public=False):
521 """
522 Create a :class:`~Stream`: from an addon resource.
524 :param path: See :func:`~odoo.tools.file_path`
525 :param filter_ext: See :func:`~odoo.tools.file_path`
526 :param bool public: Advertise the resource as being cachable by
527 intermediate proxies, otherwise only let the browser caches
528 it.
529 """
530 path = file_path(path, filter_ext)
531 check = adler32(path.encode())
532 stat = os.stat(path)
533 return cls(
534 type='path',
535 path=path,
536 mimetype=mimetypes.guess_type(path)[0],
537 download_name=os.path.basename(path),
538 etag=f'{int(stat.st_mtime)}-{stat.st_size}-{check}',
539 last_modified=stat.st_mtime,
540 size=stat.st_size,
541 public=public,
542 )
544 @classmethod
545 def from_binary_field(cls, record, field_name):
546 """ Create a :class:`~Stream`: from a binary field. """
547 data_b64 = record[field_name]
548 data = base64.b64decode(data_b64) if data_b64 else b''
549 return cls(
550 type='data',
551 data=data,
552 etag=request.env['ir.attachment']._compute_checksum(data),
553 last_modified=record.write_date if record._log_access else None,
554 size=len(data),
555 public=record.env.user._is_public() # good enough
556 )
558 def read(self):
559 """ Get the stream content as bytes. """
560 if self.type == 'url':
561 raise ValueError("Cannot read an URL")
563 if self.type == 'data':
564 return self.data
566 with open(self.path, 'rb') as file:
567 return file.read()
569 def get_response(
570 self,
571 as_attachment=None,
572 immutable=None,
573 content_security_policy="default-src 'none'",
574 **send_file_kwargs
575 ):
576 """
577 Create the corresponding :class:`~Response` for the current stream.
579 :param bool|None as_attachment: Indicate to the browser that it
580 should offer to save the file instead of displaying it.
581 :param bool|None immutable: Add the ``immutable`` directive to
582 the ``Cache-Control`` response header, allowing intermediary
583 proxies to aggressively cache the response. This option also
584 set the ``max-age`` directive to 1 year.
585 :param str|None content_security_policy: Optional value for the
586 ``Content-Security-Policy`` (CSP) header. This header is
587 used by browsers to allow/restrict the downloaded resource
588 to itself perform new http requests. By default CSP is set
589 to ``"default-scr 'none'"`` which restrict all requests.
590 :param send_file_kwargs: Other keyword arguments to send to
591 :func:`odoo.tools._vendor.send_file.send_file` instead of
592 the stream sensitive values. Discouraged.
593 """
594 assert self.type in ('url', 'data', 'path'), "Invalid type: {self.type!r}, should be 'url', 'data' or 'path'."
595 assert getattr(self, self.type) is not None, "There is nothing to stream, missing {self.type!r} attribute."
597 if self.type == 'url':
598 if self.max_age is not None:
599 res = request.redirect(self.url, code=302, local=False)
600 res.headers['Cache-Control'] = f'max-age={self.max_age}'
601 return res
602 return request.redirect(self.url, code=301, local=False)
604 if as_attachment is None:
605 as_attachment = self.as_attachment
606 if immutable is None:
607 immutable = self.immutable
609 send_file_kwargs = {
610 'mimetype': self.mimetype,
611 'as_attachment': as_attachment,
612 'download_name': self.download_name,
613 'conditional': self.conditional,
614 'etag': self.etag,
615 'last_modified': self.last_modified,
616 'max_age': STATIC_CACHE_LONG if immutable else self.max_age,
617 'environ': request.httprequest.environ,
618 'response_class': Response,
619 **send_file_kwargs,
620 }
622 if self.type == 'data':
623 res = _send_file(BytesIO(self.data), **send_file_kwargs)
624 else: # self.type == 'path'
625 send_file_kwargs['use_x_sendfile'] = False
626 if config['x_sendfile']:
627 with contextlib.suppress(ValueError): # outside of the filestore
628 fspath = Path(self.path).relative_to(opj(config['data_dir'], 'filestore'))
629 x_accel_redirect = f'/web/filestore/{fspath}'
630 send_file_kwargs['use_x_sendfile'] = True
632 res = _send_file(self.path, **send_file_kwargs)
633 if 'X-Sendfile' in res.headers:
634 res.headers['X-Accel-Redirect'] = x_accel_redirect
636 # In case of X-Sendfile/X-Accel-Redirect, the body is empty,
637 # yet werkzeug gives the length of the file. This makes
638 # NGINX wait for content that'll never arrive.
639 res.headers['Content-Length'] = '0'
641 res.headers['X-Content-Type-Options'] = 'nosniff'
643 if content_security_policy: # see also Application.set_csp()
644 res.headers['Content-Security-Policy'] = content_security_policy
646 if self.public:
647 if (res.cache_control.max_age or 0) > 0:
648 res.cache_control.public = True
649 else:
650 res.cache_control.pop('public', '')
651 res.cache_control.private = True
652 if immutable:
653 res.cache_control['immutable'] = None # None sets the directive
655 return res
658# =========================================================
659# Controller and routes
660# =========================================================
662class Controller:
663 """
664 Class mixin that provide module controllers the ability to serve
665 content over http and to be extended in child modules.
667 Each class :ref:`inheriting <python:tut-inheritance>` from
668 :class:`~odoo.http.Controller` can use the :func:`~odoo.http.route`:
669 decorator to route matching incoming web requests to decorated
670 methods.
672 Like models, controllers can be extended by other modules. The
673 extension mechanism is different because controllers can work in a
674 database-free environment and therefore cannot use
675 :class:~odoo.api.Registry:.
677 To *override* a controller, :ref:`inherit <python:tut-inheritance>`
678 from its class, override relevant methods and re-expose them with
679 :func:`~odoo.http.route`:. Please note that the decorators of all
680 methods are combined, if the overriding method’s decorator has no
681 argument all previous ones will be kept, any provided argument will
682 override previously defined ones.
684 .. code-block:
686 class GreetingController(odoo.http.Controller):
687 @route('/greet', type='http', auth='public')
688 def greeting(self):
689 return 'Hello'
691 class UserGreetingController(GreetingController):
692 @route(auth='user') # override auth, keep path and type
693 def greeting(self):
694 return super().handler()
695 """
696 children_classes = collections.defaultdict(list) # indexed by module
698 @classmethod
699 def __init_subclass__(cls):
700 super().__init_subclass__()
701 if Controller in cls.__bases__:
702 path = cls.__module__.split('.')
703 module = path[2] if path[:2] == ['odoo', 'addons'] else ''
704 Controller.children_classes[module].append(cls)
706 @property
707 def env(self):
708 return request.env if request else None
711def route(route=None, **routing):
712 """
713 Decorate a controller method in order to route incoming requests
714 matching the given URL and options to the decorated method.
716 .. warning::
717 It is mandatory to re-decorate any method that is overridden in
718 controller extensions but the arguments can be omitted. See
719 :class:`~odoo.http.Controller` for more details.
721 :param Union[str, Iterable[str]] route: The paths that the decorated
722 method is serving. Incoming HTTP request paths matching this
723 route will be routed to this decorated method. See `werkzeug
724 routing documentation <http://werkzeug.pocoo.org/docs/routing/>`_
725 for the format of route expressions.
726 :param str type: The type of request, either ``'jsonrpc'`` or
727 ``'http'``. It describes where to find the request parameters
728 and how to serialize the response.
729 :param str auth: The authentication method, one of the following:
731 * ``'user'``: The user must be authenticated and the current
732 request will be executed using the rights of the user.
733 * ``'bearer'``: The user is authenticated using an "Authorization"
734 request header, using the Bearer scheme with an API token.
735 The request will be executed with the permissions of the
736 corresponding user. If the header is missing, the request
737 must belong to an authentication session, as for the "user"
738 authentication method.
739 * ``'public'``: The user may or may not be authenticated. If he
740 isn't, the current request will be executed using the shared
741 Public user.
742 * ``'none'``: The method is always active, even if there is no
743 database. Mainly used by the framework and authentication
744 modules. The request code will not have any facilities to
745 access the current user.
746 :param Iterable[str] methods: A list of http methods (verbs) this
747 route applies to. If not specified, all methods are allowed.
748 :param str cors: The Access-Control-Allow-Origin cors directive value.
749 :param bool csrf: Whether CSRF protection should be enabled for the
750 route. Enabled by default for ``'http'``-type requests, disabled
751 by default for ``'jsonrpc'``-type requests.
752 :param Union[bool, Callable[[registry, request], bool]] readonly:
753 Whether this endpoint should open a cursor on a read-only
754 replica instead of (by default) the primary read/write database.
755 :param Callable[[Exception], Response] handle_params_access_error:
756 Implement a custom behavior if an error occurred when retrieving
757 the record from the URL parameters (access error or missing error).
758 :param str captcha: The action name of the captcha. When set the
759 request will be validated against a captcha implementation. Upon
760 failing these requests will return a UserError.
761 :param bool save_session: Whether it should set a session_id cookie
762 on the http response and save dirty session on disk. ``False``
763 by default for ``auth='bearer'``. ``True`` by default otherwise.
764 """
765 def decorator(endpoint):
766 fname = f"<function {endpoint.__module__}.{endpoint.__name__}>"
768 # Sanitize the routing
769 if routing.get('type') == 'json': 769 ↛ 770line 769 didn't jump to line 770 because the condition on line 769 was never true
770 warnings.warn(
771 "Since 19.0, @route(type='json') is a deprecated alias to @route(type='jsonrpc')",
772 DeprecationWarning,
773 stacklevel=3,
774 )
775 routing['type'] = 'jsonrpc'
776 assert routing.get('type', 'http') in _dispatchers.keys(), \
777 f"@route(type={routing['type']!r}) is not one of {_dispatchers.keys()}"
778 if route:
779 routing['routes'] = [route] if isinstance(route, str) else route
780 wrong = routing.pop('method', None)
781 if wrong is not None: 781 ↛ 782line 781 didn't jump to line 782 because the condition on line 781 was never true
782 _logger.warning("%s defined with invalid routing parameter 'method', assuming 'methods'", fname)
783 routing['methods'] = wrong
784 if routing.get('auth') == 'bearer':
785 routing.setdefault('save_session', False) # stateless
787 @functools.wraps(endpoint)
788 def route_wrapper(self, *args, **params):
789 params_ok = filter_kwargs(endpoint, params)
790 params_ko = set(params) - set(params_ok)
791 if params_ko:
792 _logger.warning("%s called ignoring args %s", fname, params_ko)
794 result = endpoint(self, *args, **params_ok)
795 if routing['type'] == 'http': # _generate_routing_rules() ensures type is set
796 return Response.load(result)
797 return result
799 route_wrapper.original_routing = routing
800 route_wrapper.original_endpoint = endpoint
801 return route_wrapper
802 return decorator
805def _generate_routing_rules(modules, nodb_only, converters=None):
806 """
807 Two-fold algorithm used to (1) determine which method in the
808 controller inheritance tree should bind to what URL with respect to
809 the list of installed modules and (2) merge the various @route
810 arguments of said method with the @route arguments of the method it
811 overrides.
812 """
813 def is_valid(cls):
814 """ Determine if the class is defined in an addon. """
815 path = cls.__module__.split('.')
816 return path[:2] == ['odoo', 'addons'] and path[2] in modules
818 def get_leaf_classes(cls):
819 """
820 Find the classes that have no child and that have ``cls`` as
821 ancestor.
822 """
823 result = []
824 for subcls in cls.__subclasses__():
825 if is_valid(subcls):
826 result.extend(get_leaf_classes(subcls))
827 if not result and is_valid(cls):
828 result.append(cls)
829 return result
831 def build_controllers():
832 """
833 Create dummy controllers that inherit only from the controllers
834 defined at the given ``modules`` (often system wide modules or
835 installed modules). Modules in this context are Odoo addons.
836 """
837 # Controllers defined outside of odoo addons are outside of the
838 # controller inheritance/extension mechanism.
839 yield from (ctrl() for ctrl in Controller.children_classes.get('', []))
841 # Controllers defined inside of odoo addons can be extended in
842 # other installed addons. Rebuild the class inheritance here.
843 highest_controllers = []
844 for module in modules:
845 highest_controllers.extend(Controller.children_classes.get(module, []))
847 for top_ctrl in highest_controllers:
848 leaf_controllers = list(unique(get_leaf_classes(top_ctrl)))
850 name = top_ctrl.__name__
851 if leaf_controllers != [top_ctrl]:
852 name += ' (extended by %s)' % ', '.join(
853 bot_ctrl.__name__
854 for bot_ctrl in leaf_controllers
855 if bot_ctrl is not top_ctrl
856 )
858 Ctrl = type(name, tuple(reversed(leaf_controllers)), {})
859 yield Ctrl()
861 for ctrl in build_controllers():
862 for method_name, method in inspect.getmembers(ctrl, inspect.ismethod):
864 # Skip this method if it is not @route decorated anywhere in
865 # the hierarchy
866 def is_method_a_route(cls):
867 return getattr(getattr(cls, method_name, None), 'original_routing', None) is not None
868 if not any(map(is_method_a_route, type(ctrl).mro())):
869 continue
871 merged_routing = {
872 # 'type': 'http', # set below
873 'auth': 'user',
874 'methods': None,
875 'routes': [],
876 }
878 for cls in unique(reversed(type(ctrl).mro()[:-2])): # ancestors first
879 if method_name not in cls.__dict__:
880 continue
881 submethod = getattr(cls, method_name)
883 if not hasattr(submethod, 'original_routing'):
884 _logger.warning("The endpoint %s is not decorated by @route(), decorating it myself.", f'{cls.__module__}.{cls.__name__}.{method_name}')
885 submethod = route()(submethod)
887 _check_and_complete_route_definition(cls, submethod, merged_routing)
889 merged_routing.update(submethod.original_routing)
891 if not merged_routing['routes']:
892 _logger.warning("%s is a controller endpoint without any route, skipping.", f'{cls.__module__}.{cls.__name__}.{method_name}')
893 continue
895 if nodb_only and merged_routing['auth'] != "none":
896 continue
898 for url in merged_routing['routes']:
899 # duplicates the function (partial) with a copy of the
900 # original __dict__ (update_wrapper) to keep a reference
901 # to `original_routing` and `original_endpoint`, assign
902 # the merged routing ONLY on the duplicated function to
903 # ensure method's immutability.
904 endpoint = functools.partial(method)
905 functools.update_wrapper(endpoint, method)
906 endpoint.routing = merged_routing
908 yield (url, endpoint)
911def _check_and_complete_route_definition(controller_cls, submethod, merged_routing):
912 """Verify and complete the route definition.
914 * Ensure 'type' is defined on each method's own routing.
915 * Ensure overrides don't change the routing type or the read/write mode
917 :param submethod: route method
918 :param dict merged_routing: accumulated routing values
919 """
920 default_type = submethod.original_routing.get('type', 'http')
921 routing_type = merged_routing.setdefault('type', default_type)
922 if submethod.original_routing.get('type') not in (None, routing_type):
923 _logger.warning(
924 "The endpoint %s changes the route type, using the original type: %r.",
925 f'{controller_cls.__module__}.{controller_cls.__name__}.{submethod.__name__}',
926 routing_type)
927 submethod.original_routing['type'] = routing_type
929 default_auth = submethod.original_routing.get('auth', merged_routing['auth'])
930 default_mode = submethod.original_routing.get('readonly', default_auth == 'none')
931 parent_readonly = merged_routing.setdefault('readonly', default_mode)
932 child_readonly = submethod.original_routing.get('readonly')
933 if child_readonly not in (None, parent_readonly) and not callable(child_readonly):
934 _logger.warning(
935 "The endpoint %s made the route %s altough its parent was defined as %s. Setting the route read/write.",
936 f'{controller_cls.__module__}.{controller_cls.__name__}.{submethod.__name__}',
937 'readonly' if child_readonly else 'read/write',
938 'readonly' if parent_readonly else 'read/write',
939 )
940 submethod.original_routing['readonly'] = False
943# =========================================================
944# Session
945# =========================================================
947_base64_urlsafe_re = re.compile(r'^[A-Za-z0-9_-]{84}$')
948_session_identifier_re = re.compile(r'^[A-Za-z0-9_-]{%s}$' % STORED_SESSION_BYTES)
951class FilesystemSessionStore(sessions.FilesystemSessionStore):
952 """ Place where to load and save session objects. """
953 def get_session_filename(self, sid):
954 # scatter sessions across 4096 (64^2) directories
955 if not self.is_valid_key(sid):
956 raise ValueError(f'Invalid session id {sid!r}')
957 sha_dir = sid[:2]
958 dirname = os.path.join(self.path, sha_dir)
959 session_path = os.path.join(dirname, sid)
960 return session_path
962 def save(self, session):
963 session_path = self.get_session_filename(session.sid)
964 dirname = os.path.dirname(session_path)
965 if not os.path.isdir(dirname):
966 with contextlib.suppress(OSError):
967 os.mkdir(dirname, 0o0755)
968 super().save(session)
970 def delete_old_sessions(self, session):
971 if 'gc_previous_sessions' in session:
972 if session['create_time'] + SESSION_DELETION_TIMER < time.time():
973 self.delete_from_identifiers([session.sid[:STORED_SESSION_BYTES]])
974 del session['gc_previous_sessions']
975 self.save(session)
977 def get(self, sid):
978 # retro compatibility
979 old_path = super().get_session_filename(sid)
980 session_path = self.get_session_filename(sid)
981 if os.path.isfile(old_path) and not os.path.isfile(session_path):
982 dirname = os.path.dirname(session_path)
983 if not os.path.isdir(dirname):
984 with contextlib.suppress(OSError):
985 os.mkdir(dirname, 0o0755)
986 with contextlib.suppress(OSError):
987 os.rename(old_path, session_path)
988 session = super().get(sid)
989 return session
991 def rotate(self, session, env, soft=False):
992 # With a soft rotation, things like the CSRF token will still work. It's used for rotating
993 # the session in a way that half the bytes remain to identify the user and the other half
994 # to authenticate the user. Meanwhile with a hard rotation the entire session id is changed,
995 # which is useful in cases such as logging the user out.
996 if soft:
997 # Multiple network requests can occur at the same time, all using the old session.
998 # We don't want to create a new session for each request, it's better to reference the one already made.
999 static = session.sid[:STORED_SESSION_BYTES]
1000 recent_session = self.get(session.sid)
1001 if 'next_sid' in recent_session:
1002 # A new session has already been saved on disk by a concurrent request,
1003 # the _save_session is going to simply use session.sid to set a new cookie.
1004 session.sid = recent_session['next_sid']
1005 return
1006 next_sid = static + self.generate_key()[STORED_SESSION_BYTES:]
1007 session['next_sid'] = next_sid
1008 session['deletion_time'] = time.time() + SESSION_DELETION_TIMER
1009 self.save(session)
1010 # Now prepare the new session
1011 session['gc_previous_sessions'] = True
1012 session.sid = next_sid
1013 del session['deletion_time']
1014 del session['next_sid']
1015 else:
1016 self.delete(session)
1017 session.sid = self.generate_key()
1018 if session.uid:
1019 assert env, "saving this session requires an environment"
1020 session.session_token = security.compute_session_token(session, env)
1021 session.should_rotate = False
1022 session['create_time'] = time.time()
1023 self.save(session)
1025 def vacuum(self, max_lifetime=SESSION_LIFETIME):
1026 threshold = time.time() - max_lifetime
1027 for fname in glob.iglob(os.path.join(root.session_store.path, '*', '*')):
1028 path = os.path.join(root.session_store.path, fname)
1029 with contextlib.suppress(OSError):
1030 if os.path.getmtime(path) < threshold:
1031 os.unlink(path)
1033 def generate_key(self, salt=None):
1034 # The generated key is case sensitive (base64) and the length is 84 chars.
1035 # In the worst-case scenario, i.e. in an insensitive filesystem (NTFS for example)
1036 # taking into account the proportion of characters in the pool and a length
1037 # of 42 (stored part in the database), the entropy for the base64 generated key
1038 # is 217.875 bits which is better than the 160 bits entropy of a hexadecimal key
1039 # with a length of 40 (method ``generate_key`` of ``SessionStore``).
1040 # The risk of collision is negligible in practice.
1041 # Formulas:
1042 # - L: length of generated word
1043 # - p_char: probability of obtaining the character in the pool
1044 # - n: size of the pool
1045 # - k: number of generated word
1046 # Entropy = - L * sum(p_char * log2(p_char))
1047 # Collision ~= (1 - exp((-k * (k - 1)) / (2 * (n**L))))
1048 key = str(time.time()).encode() + os.urandom(64)
1049 hash_key = sha512(key).digest()[:-1] # prevent base64 padding
1050 return base64.urlsafe_b64encode(hash_key).decode('utf-8')
1052 def is_valid_key(self, key):
1053 return _base64_urlsafe_re.match(key) is not None
1055 def get_missing_session_identifiers(self, identifiers):
1056 """
1057 :param identifiers: session identifiers whose file existence must be checked
1058 identifiers are a part session sid (first 42 chars)
1059 :type identifiers: iterable
1060 :return: the identifiers which are not present on the filesystem
1061 :rtype: set
1062 """
1063 # There are a lot of session files.
1064 # Use the param ``identifiers`` to select the necessary directories.
1065 # In the worst case, we have 4096 directories (64^2).
1066 identifiers = set(identifiers)
1067 directories = {
1068 os.path.normpath(os.path.join(self.path, identifier[:2]))
1069 for identifier in identifiers
1070 }
1071 # Remove the identifiers for which a file is present on the filesystem.
1072 for directory in directories:
1073 with contextlib.suppress(OSError), os.scandir(directory) as session_files:
1074 identifiers.difference_update(sf.name[:42] for sf in session_files)
1075 return identifiers
1077 def delete_from_identifiers(self, identifiers: list):
1078 files_to_unlink = []
1079 for identifier in identifiers:
1080 # Avoid to remove a session if it does not match an identifier.
1081 # This prevent malicious user to delete sessions from a different
1082 # database by specifying a custom ``res.device.log``.
1083 if not _session_identifier_re.match(identifier):
1084 raise ValueError("Identifier format incorrect, did you pass in a string instead of a list?")
1085 normalized_path = os.path.normpath(os.path.join(self.path, identifier[:2], identifier + '*'))
1086 if normalized_path.startswith(self.path):
1087 files_to_unlink.extend(glob.glob(normalized_path))
1088 for fn in files_to_unlink:
1089 with contextlib.suppress(OSError):
1090 os.unlink(fn)
1093class Session(collections.abc.MutableMapping):
1094 """ Structure containing data persisted across requests. """
1095 __slots__ = ('can_save', '_Session__data', 'is_dirty', 'is_new',
1096 'should_rotate', 'sid')
1098 def __init__(self, data, sid, new=False):
1099 self.can_save = True
1100 self.__data = {}
1101 self.update(data)
1102 self.is_dirty = False
1103 self.is_new = new
1104 self.should_rotate = False
1105 self.sid = sid
1107 def __getitem__(self, item):
1108 return self.__data[item]
1110 def __setitem__(self, item, value):
1111 value = json.loads(json.dumps(value))
1112 if item not in self.__data or self.__data[item] != value:
1113 self.is_dirty = True
1114 self.__data[item] = value
1116 def __delitem__(self, item):
1117 del self.__data[item]
1118 self.is_dirty = True
1120 def __len__(self):
1121 return len(self.__data)
1123 def __iter__(self):
1124 return iter(self.__data)
1126 def clear(self):
1127 self.__data.clear()
1128 self.is_dirty = True
1130 #
1131 # Session properties
1132 #
1133 @property
1134 def uid(self):
1135 return self.get('uid')
1137 @uid.setter
1138 def uid(self, uid):
1139 self['uid'] = uid
1141 @property
1142 def db(self):
1143 return self.get('db')
1145 @db.setter
1146 def db(self, db):
1147 self['db'] = db
1149 @property
1150 def login(self):
1151 return self.get('login')
1153 @login.setter
1154 def login(self, login):
1155 self['login'] = login
1157 @property
1158 def context(self):
1159 return self.get('context')
1161 @context.setter
1162 def context(self, context):
1163 self['context'] = context
1165 @property
1166 def debug(self):
1167 return self.get('debug')
1169 @debug.setter
1170 def debug(self, debug):
1171 self['debug'] = debug
1173 @property
1174 def session_token(self):
1175 return self.get('session_token')
1177 @session_token.setter
1178 def session_token(self, session_token):
1179 self['session_token'] = session_token
1181 #
1182 # Session methods
1183 #
1184 def authenticate(self, env, credential):
1185 """
1186 Authenticate the current user with the given db, login and
1187 credential. If successful, store the authentication parameters in
1188 the current session, unless multi-factor-auth (MFA) is
1189 activated. In that case, that last part will be done by
1190 :ref:`finalize`.
1192 .. versionchanged:: saas-15.3
1193 The current request is no longer updated using the user and
1194 context of the session when the authentication is done using
1195 a database different than request.db. It is up to the caller
1196 to open a new cursor/registry/env on the given database.
1197 """
1198 wsgienv = {
1199 'interactive': True,
1200 'base_location': request.httprequest.url_root.rstrip('/'),
1201 'HTTP_HOST': request.httprequest.environ['HTTP_HOST'],
1202 'REMOTE_ADDR': request.httprequest.environ['REMOTE_ADDR'],
1203 }
1204 env = env(user=None, su=False)
1205 auth_info = env['res.users'].authenticate(credential, wsgienv)
1206 pre_uid = auth_info['uid']
1208 self.uid = None
1209 self['pre_login'] = credential['login']
1210 self['pre_uid'] = pre_uid
1212 # if 2FA is disabled we finalize immediately
1213 user = env['res.users'].browse(pre_uid)
1214 if auth_info.get('mfa') == 'skip' or not user._mfa_url():
1215 self.finalize(env)
1217 if request and request.session is self and request.db == env.registry.db_name:
1218 request.env = env(user=self.uid, context=self.context)
1219 request.update_context(lang=get_lang(request.env(user=pre_uid)).code)
1221 return auth_info
1223 def finalize(self, env):
1224 """
1225 Finalizes a partial session, should be called on MFA validation
1226 to convert a partial / pre-session into a logged-in one.
1227 """
1228 login = self.pop('pre_login')
1229 uid = self.pop('pre_uid')
1231 env = env(user=uid)
1232 user_context = dict(env['res.users'].context_get())
1234 self.should_rotate = True
1235 self.update({
1236 'db': env.registry.db_name,
1237 'login': login,
1238 'uid': uid,
1239 'context': user_context,
1240 'session_token': env.user._compute_session_token(self.sid),
1241 })
1243 def logout(self, keep_db=False):
1244 db = self.db if keep_db else get_default_session()['db'] # None
1245 debug = self.debug
1246 self.clear()
1247 self.update(get_default_session(), db=db, debug=debug)
1248 self.context['lang'] = request.default_lang() if request else DEFAULT_LANG
1249 self.should_rotate = True
1251 if request and request.env:
1252 request.env['ir.http']._post_logout()
1254 def touch(self):
1255 self.is_dirty = True
1257 def update_trace(self, request):
1258 """
1259 :return: dict if a device log has to be inserted, ``None`` otherwise
1260 """
1261 if self.get('_trace_disable'):
1262 # To avoid generating useless logs, e.g. for automated technical sessions,
1263 # a session can be flagged with `_trace_disable`. This should never be done
1264 # without a proper assessment of the consequences for auditability.
1265 # Non-admin users have no direct or indirect way to set this flag, so it can't
1266 # be abused by unprivileged users. Such sessions will of course still be
1267 # subject to all other auditing mechanisms (server logs, web proxy logs,
1268 # metadata tracking on modified records, etc.)
1269 return
1271 user_agent = request.httprequest.user_agent
1272 platform = user_agent.platform
1273 browser = user_agent.browser
1274 ip_address = request.httprequest.remote_addr
1275 now = int(datetime.now().timestamp())
1276 for trace in self['_trace']:
1277 if trace['platform'] == platform and trace['browser'] == browser and trace['ip_address'] == ip_address:
1278 # If the device logs are not up to date (i.e. not updated for one hour or more)
1279 if bool(now - trace['last_activity'] >= 3600):
1280 trace['last_activity'] = now
1281 self.is_dirty = True
1282 return trace
1283 return
1284 new_trace = {
1285 'platform': platform,
1286 'browser': browser,
1287 'ip_address': ip_address,
1288 'first_activity': now,
1289 'last_activity': now
1290 }
1291 self['_trace'].append(new_trace)
1292 self.is_dirty = True
1293 return new_trace
1295 def _delete_old_sessions(self):
1296 root.session_store.delete_old_sessions(self)
1299# =========================================================
1300# GeoIP
1301# =========================================================
1303class GeoIP(collections.abc.Mapping):
1304 """
1305 Ip Geolocalization utility, determine information such as the
1306 country or the timezone of the user based on their IP Address.
1308 The instances share the same API as `:class:`geoip2.models.City`
1309 <https://geoip2.readthedocs.io/en/latest/#geoip2.models.City>`_.
1311 When the IP couldn't be geolocalized (missing database, bad address)
1312 then an empty object is returned. This empty object can be used like
1313 a regular one with the exception that all info are set None.
1315 :param str ip: The IP Address to geo-localize
1317 .. note:
1319 The geoip info the the current request are available at
1320 :attr:`~odoo.http.request.geoip`.
1322 .. code-block:
1324 >>> GeoIP('127.0.0.1').country.iso_code
1325 >>> odoo_ip = socket.gethostbyname('odoo.com')
1326 >>> GeoIP(odoo_ip).country.iso_code
1327 'FR'
1328 """
1330 def __init__(self, ip):
1331 self.ip = ip
1333 @functools.cached_property
1334 def _city_record(self):
1335 try:
1336 return root.geoip_city_db.city(self.ip)
1337 except (OSError, maxminddb.InvalidDatabaseError):
1338 return GEOIP_EMPTY_CITY
1339 except geoip2.errors.AddressNotFoundError:
1340 return GEOIP_EMPTY_CITY
1342 @functools.cached_property
1343 def _country_record(self):
1344 if '_city_record' in vars(self):
1345 # the City class inherits from the Country class and the
1346 # city record is in cache already, save a geolocalization
1347 return self._city_record
1348 try:
1349 return root.geoip_country_db.country(self.ip)
1350 except (OSError, maxminddb.InvalidDatabaseError):
1351 return self._city_record
1352 except geoip2.errors.AddressNotFoundError:
1353 return GEOIP_EMPTY_COUNTRY
1355 @property
1356 def country_name(self):
1357 return self.country.name or self.continent.name
1359 @property
1360 def country_code(self):
1361 return self.country.iso_code or self.continent.code
1363 def __getattr__(self, attr):
1364 # Be smart and determine whether the attribute exists on the
1365 # country object or on the city object.
1366 if hasattr(GEOIP_EMPTY_COUNTRY, attr):
1367 return getattr(self._country_record, attr)
1368 if hasattr(GEOIP_EMPTY_CITY, attr):
1369 return getattr(self._city_record, attr)
1370 raise AttributeError(f"{self} has no attribute {attr!r}")
1372 def __bool__(self):
1373 return self.country_name is not None
1375 # Old dict API, undocumented for now, will be deprecated some day
1376 def __getitem__(self, item):
1377 if item == 'country_name':
1378 return self.country_name
1380 if item == 'country_code':
1381 return self.country_code
1383 if item == 'city':
1384 return self.city.name
1386 if item == 'latitude':
1387 return self.location.latitude
1389 if item == 'longitude':
1390 return self.location.longitude
1392 if item == 'region':
1393 return self.subdivisions[0].iso_code if self.subdivisions else None
1395 if item == 'time_zone':
1396 return self.location.time_zone
1398 raise KeyError(item)
1400 def __iter__(self):
1401 raise NotImplementedError("The dictionnary GeoIP API is deprecated.")
1403 def __len__(self):
1404 raise NotImplementedError("The dictionnary GeoIP API is deprecated.")
1407# =========================================================
1408# Request and Response
1409# =========================================================
1411# Thread local global request object
1412_request_stack = werkzeug.local.LocalStack()
1413request = _request_stack()
1415@contextlib.contextmanager
1416def borrow_request():
1417 """ Get the current request and unexpose it from the local stack. """
1418 req = _request_stack.pop()
1419 try:
1420 yield req
1421 finally:
1422 _request_stack.push(req)
1425def make_request_wrap_methods(attr):
1426 def getter(self):
1427 return getattr(self._HTTPRequest__wrapped, attr)
1429 def setter(self, value):
1430 return setattr(self._HTTPRequest__wrapped, attr, value)
1432 return getter, setter
1435class HTTPRequest:
1436 def __init__(self, environ):
1437 httprequest = werkzeug.wrappers.Request(environ)
1438 httprequest.user_agent_class = UserAgent # use vendored userAgent since it will be removed in 2.1
1439 httprequest.parameter_storage_class = werkzeug.datastructures.ImmutableMultiDict
1440 httprequest.max_content_length = DEFAULT_MAX_CONTENT_LENGTH
1441 httprequest.max_form_memory_size = 10 * 1024 * 1024 # 10 MB
1442 self._session_id__ = httprequest.cookies.get('session_id')
1444 self.__wrapped = httprequest
1445 self.__environ = self.__wrapped.environ
1446 self.environ = self.headers.environ = {
1447 key: value
1448 for key, value in self.__environ.items()
1449 if (not key.startswith(('werkzeug.', 'wsgi.', 'socket')) or key in ['wsgi.url_scheme', 'werkzeug.proxy_fix.orig'])
1450 }
1452 def __enter__(self):
1453 return self
1456HTTPREQUEST_ATTRIBUTES = [
1457 '__str__', '__repr__', '__exit__',
1458 'accept_charsets', 'accept_languages', 'accept_mimetypes', 'access_route', 'args', 'authorization', 'base_url',
1459 'charset', 'content_encoding', 'content_length', 'content_md5', 'content_type', 'cookies', 'data', 'date',
1460 'encoding_errors', 'files', 'form', 'full_path', 'get_data', 'get_json', 'headers', 'host', 'host_url', 'if_match',
1461 'if_modified_since', 'if_none_match', 'if_range', 'if_unmodified_since', 'is_json', 'is_secure', 'json',
1462 'max_content_length', 'method', 'mimetype', 'mimetype_params', 'origin', 'path', 'pragma', 'query_string', 'range',
1463 'referrer', 'remote_addr', 'remote_user', 'root_path', 'root_url', 'scheme', 'script_root', 'server', 'session',
1464 'trusted_hosts', 'url', 'url_charset', 'url_root', 'user_agent', 'values',
1465]
1466for attr in HTTPREQUEST_ATTRIBUTES:
1467 setattr(HTTPRequest, attr, property(*make_request_wrap_methods(attr)))
1470class _Response(werkzeug.wrappers.Response):
1471 """
1472 Outgoing HTTP response with body, status, headers and qweb support.
1473 In addition to the :class:`werkzeug.wrappers.Response` parameters,
1474 this class's constructor can take the following additional
1475 parameters for QWeb Lazy Rendering.
1477 :param str template: template to render
1478 :param dict qcontext: Rendering context to use
1479 :param int uid: User id to use for the ir.ui.view render call,
1480 ``None`` to use the request's user (the default)
1482 these attributes are available as parameters on the Response object
1483 and can be altered at any time before rendering
1485 Also exposes all the attributes and methods of
1486 :class:`werkzeug.wrappers.Response`.
1487 """
1488 default_mimetype = 'text/html'
1490 def __init__(self, *args, **kw):
1491 template = kw.pop('template', None)
1492 qcontext = kw.pop('qcontext', None)
1493 uid = kw.pop('uid', None)
1494 super().__init__(*args, **kw)
1495 self.set_default(template, qcontext, uid)
1497 @classmethod
1498 def load(cls, result, fname="<function>"):
1499 """
1500 Convert the return value of an endpoint into a Response.
1502 :param result: The endpoint return value to load the Response from.
1503 :type result: Union[Response, werkzeug.wrappers.BaseResponse,
1504 werkzeug.exceptions.HTTPException, str, bytes, NoneType]
1505 :param str fname: The endpoint function name wherefrom the
1506 result emanated, used for logging.
1507 :returns: The created :class:`~odoo.http.Response`.
1508 :rtype: Response
1509 :raises TypeError: When ``result`` type is none of the above-
1510 mentioned type.
1511 """
1512 if isinstance(result, Response):
1513 return result
1515 if isinstance(result, werkzeug.exceptions.HTTPException):
1516 _logger.warning("%s returns an HTTPException instead of raising it.", fname)
1517 raise result
1519 if isinstance(result, werkzeug.wrappers.Response):
1520 response = cls.force_type(result)
1521 response.set_default()
1522 return response
1524 if isinstance(result, (bytes, str, type(None))):
1525 return Response(result)
1527 raise TypeError(f"{fname} returns an invalid value: {result}")
1529 def set_default(self, template=None, qcontext=None, uid=None):
1530 self.template = template
1531 self.qcontext = qcontext or dict()
1532 self.qcontext['response_template'] = self.template
1533 self.uid = uid
1535 @property
1536 def is_qweb(self):
1537 return self.template is not None
1539 def render(self):
1540 """ Renders the Response's template, returns the result. """
1541 self.qcontext['request'] = request
1542 return request.env["ir.ui.view"]._render_template(self.template, self.qcontext)
1544 def flatten(self):
1545 """
1546 Forces the rendering of the response's template, sets the result
1547 as response body and unsets :attr:`.template`
1548 """
1549 if self.template:
1550 self.response.append(self.render())
1551 self.template = None
1553 def set_cookie(self, key, value='', max_age=None, expires=-1, path='/', domain=None, secure=False, httponly=False, samesite=None, cookie_type='required'):
1554 """
1555 The default expires in Werkzeug is None, which means a session cookie.
1556 We want to continue to support the session cookie, but not by default.
1557 Now the default is arbitrary 1 year.
1558 So if you want a cookie of session, you have to explicitly pass expires=None.
1559 """
1560 if expires == -1: # not provided value -> default value -> 1 year
1561 expires = datetime.now() + timedelta(days=365)
1563 if request.db and not request.env['ir.http']._is_allowed_cookie(cookie_type):
1564 max_age = 0
1565 super().set_cookie(key, value=value, max_age=max_age, expires=expires, path=path, domain=domain, secure=secure, httponly=httponly, samesite=samesite)
1568class Headers(Proxy):
1569 _wrapped__ = werkzeug.datastructures.Headers
1571 __getitem__ = ProxyFunc()
1572 __repr__ = ProxyFunc(str)
1573 __setitem__ = ProxyFunc(None)
1574 __str__ = ProxyFunc(str)
1575 __contains__ = ProxyFunc(bool)
1576 add = ProxyFunc(None)
1577 add_header = ProxyFunc(None)
1578 clear = ProxyFunc(None)
1579 copy = ProxyFunc(lambda v: Headers(v)) # noqa: PLW0108
1580 extend = ProxyFunc(None)
1581 get = ProxyFunc()
1582 get_all = ProxyFunc()
1583 getlist = ProxyFunc()
1584 items = ProxyFunc()
1585 keys = ProxyFunc()
1586 pop = ProxyFunc()
1587 popitem = ProxyFunc()
1588 remove = ProxyFunc(None)
1589 set = ProxyFunc(None)
1590 setdefault = ProxyFunc()
1591 setlist = ProxyFunc(None)
1592 setlistdefault = ProxyFunc()
1593 to_wsgi_list = ProxyFunc()
1594 update = ProxyFunc(None)
1595 values = ProxyFunc()
1598class ResponseCacheControl(Proxy):
1599 _wrapped__ = werkzeug.datastructures.ResponseCacheControl
1601 __getitem__ = ProxyFunc()
1602 __setitem__ = ProxyFunc(None)
1603 immutable = ProxyAttr(bool)
1604 max_age = ProxyAttr(int)
1605 must_revalidate = ProxyAttr(bool)
1606 no_cache = ProxyAttr(bool)
1607 no_store = ProxyAttr(bool)
1608 no_transform = ProxyAttr(bool)
1609 public = ProxyAttr(bool)
1610 private = ProxyAttr(bool)
1611 proxy_revalidate = ProxyAttr(bool)
1612 s_maxage = ProxyAttr(int)
1613 pop = ProxyFunc()
1616class ResponseStream(Proxy):
1617 _wrapped__ = werkzeug.wrappers.ResponseStream
1619 write = ProxyFunc(int)
1620 writelines = ProxyFunc(None)
1621 tell = ProxyFunc(int)
1624class Response(Proxy):
1625 _wrapped__ = _Response
1627 # werkzeug.wrappers.Response attributes
1628 __call__ = ProxyFunc()
1629 add_etag = ProxyFunc(None)
1630 age = ProxyAttr()
1631 autocorrect_location_header = ProxyAttr(bool)
1632 cache_control = ProxyAttr(ResponseCacheControl)
1633 call_on_close = ProxyFunc()
1634 charset = ProxyAttr(str)
1635 content_encoding = ProxyAttr(str)
1636 content_length = ProxyAttr(int)
1637 content_location = ProxyAttr(str)
1638 content_md5 = ProxyAttr(str)
1639 content_type = ProxyAttr(str)
1640 data = ProxyAttr()
1641 default_mimetype = ProxyAttr(str)
1642 default_status = ProxyAttr(int)
1643 delete_cookie = ProxyFunc(None)
1644 direct_passthrough = ProxyAttr(bool)
1645 expires = ProxyAttr()
1646 force_type = ProxyFunc(lambda v: Response(v)) # noqa: PLW0108
1647 freeze = ProxyFunc(None)
1648 get_data = ProxyFunc()
1649 get_etag = ProxyFunc()
1650 get_json = ProxyFunc()
1651 headers = ProxyAttr(Headers)
1652 is_json = ProxyAttr(bool)
1653 is_sequence = ProxyAttr(bool)
1654 is_streamed = ProxyAttr(bool)
1655 iter_encoded = ProxyFunc()
1656 json = ProxyAttr()
1657 last_modified = ProxyAttr()
1658 location = ProxyAttr(str)
1659 make_conditional = ProxyFunc(lambda v: Response(v)) # noqa: PLW0108
1660 make_sequence = ProxyFunc(None)
1661 max_cookie_size = ProxyAttr(int)
1662 mimetype = ProxyAttr(str)
1663 response = ProxyAttr()
1664 retry_after = ProxyAttr()
1665 set_cookie = ProxyFunc(None)
1666 set_data = ProxyFunc(None)
1667 set_etag = ProxyFunc(None)
1668 status = ProxyAttr(str)
1669 status_code = ProxyAttr(int)
1670 stream = ProxyAttr(ResponseStream)
1672 # odoo.http._response attributes
1673 load = ProxyFunc()
1674 set_default = ProxyFunc(None)
1675 qcontext = ProxyAttr()
1676 template = ProxyAttr(str)
1677 is_qweb = ProxyAttr(bool)
1678 render = ProxyFunc()
1679 flatten = ProxyFunc(None)
1681 def __init__(self, *args, **kwargs):
1682 response = None
1683 if len(args) == 1:
1684 arg = args[0]
1685 if isinstance(arg, Response):
1686 response = arg._wrapped__
1687 elif isinstance(arg, _Response):
1688 response = arg
1689 elif isinstance(arg, werkzeug.wrappers.Response):
1690 response = _Response.load(arg)
1691 if response is None:
1692 if isinstance(kwargs.get('headers'), Headers):
1693 kwargs['headers'] = kwargs['headers']._wrapped__
1694 response = _Response(*args, **kwargs)
1696 super().__init__(response)
1697 if 'set_cookie' in response.__dict__:
1698 self.__dict__['set_cookie'] = response.__dict__['set_cookie']
1701__wz_get_response = HTTPException.get_response
1704def get_response(self, environ=None, scope=None):
1705 return Response(__wz_get_response(self, environ, scope))
1708HTTPException.get_response = get_response
1711werkzeug_abort = werkzeug.exceptions.abort
1714def abort(status, *args, **kwargs):
1715 if isinstance(status, Response):
1716 status = status._wrapped__
1717 werkzeug_abort(status, *args, **kwargs)
1720werkzeug.exceptions.abort = abort
1723class FutureResponse:
1724 """
1725 werkzeug.Response mock class that only serves as placeholder for
1726 headers to be injected in the final response.
1727 """
1728 # used by werkzeug.Response.set_cookie
1729 charset = 'utf-8'
1730 max_cookie_size = 4093
1732 def __init__(self):
1733 self.headers = werkzeug.datastructures.Headers()
1735 @property
1736 def _charset(self):
1737 return self.charset
1739 @functools.wraps(werkzeug.Response.set_cookie)
1740 def set_cookie(self, key, value='', max_age=None, expires=-1, path='/', domain=None, secure=False, httponly=False, samesite=None, cookie_type='required'):
1741 if expires == -1: # not forced value -> default value -> 1 year
1742 expires = datetime.now() + timedelta(days=365)
1744 if request.db and not request.env['ir.http']._is_allowed_cookie(cookie_type):
1745 max_age = 0
1746 werkzeug.Response.set_cookie(self, key, value=value, max_age=max_age, expires=expires, path=path, domain=domain, secure=secure, httponly=httponly, samesite=samesite)
1749class Request:
1750 """
1751 Wrapper around the incoming HTTP request with deserialized request
1752 parameters, session utilities and request dispatching logic.
1753 """
1755 def __init__(self, httprequest):
1756 self.httprequest = httprequest
1757 self.future_response = FutureResponse()
1758 self.dispatcher = _dispatchers['http'](self) # until we match
1759 #self.params = {} # set by the Dispatcher
1761 self.geoip = GeoIP(httprequest.remote_addr)
1762 self.registry = None
1763 self.env = None
1765 def _post_init(self):
1766 self.session, self.db = self._get_session_and_dbname()
1767 self._post_init = None
1769 def _get_session_and_dbname(self):
1770 sid = self.httprequest._session_id__
1771 if not sid or not root.session_store.is_valid_key(sid):
1772 session = root.session_store.new()
1773 else:
1774 session = root.session_store.get(sid)
1775 session.sid = sid # in case the session was not persisted
1777 for key, val in get_default_session().items():
1778 session.setdefault(key, val)
1779 if not session.context.get('lang'):
1780 session.context['lang'] = self.default_lang()
1782 dbname = None
1783 host = self.httprequest.environ['HTTP_HOST']
1784 header_dbname = self.httprequest.headers.get('X-Odoo-Database')
1785 if session.db and db_filter([session.db], host=host):
1786 dbname = session.db
1787 if header_dbname and header_dbname != dbname:
1788 e = ("Cannot use both the session_id cookie and the "
1789 "x-odoo-database header.")
1790 raise werkzeug.exceptions.Forbidden(e)
1791 elif header_dbname:
1792 session.can_save = False # stateless
1793 if db_filter([header_dbname], host=host):
1794 dbname = header_dbname
1795 else:
1796 all_dbs = db_list(force=True, host=host)
1797 if len(all_dbs) == 1:
1798 dbname = all_dbs[0] # monodb
1800 if session.db != dbname:
1801 if session.db:
1802 _logger.warning("Logged into database %r, but dbfilter rejects it; logging session out.", session.db)
1803 session.logout(keep_db=False)
1804 session.db = dbname
1806 session.is_dirty = False
1807 return session, dbname
1809 # =====================================================
1810 # Getters and setters
1811 # =====================================================
1812 def update_env(self, user=None, context=None, su=None):
1813 """ Update the environment of the current request.
1815 :param user: optional user/user id to change the current user
1816 :type user: int or :class:`res.users record<~odoo.addons.base.models.res_users.ResUsers>`
1817 :param dict context: optional context dictionary to change the current context
1818 :param bool su: optional boolean to change the superuser mode
1819 """
1820 cr = None # None is a sentinel, it keeps the same cursor
1821 self.env = self.env(cr, user, context, su)
1822 self.env.transaction.default_env = self.env
1823 threading.current_thread().uid = self.env.uid
1825 def update_context(self, **overrides):
1826 """
1827 Override the environment context of the current request with the
1828 values of ``overrides``. To replace the entire context, please
1829 use :meth:`~update_env` instead.
1830 """
1831 self.update_env(context=dict(self.env.context, **overrides))
1833 @property
1834 def context(self):
1835 warnings.warn("Since 19.0, use request.env.context directly", DeprecationWarning, stacklevel=2)
1836 return self.env.context
1838 @context.setter
1839 def context(self, value):
1840 raise NotImplementedError("Use request.update_context instead.")
1842 @property
1843 def uid(self):
1844 warnings.warn("Since 19.0, use request.env.uid directly", DeprecationWarning, stacklevel=2)
1845 return self.env.uid
1847 @uid.setter
1848 def uid(self, value):
1849 raise NotImplementedError("Use request.update_env instead.")
1851 @property
1852 def cr(self):
1853 warnings.warn("Since 19.0, use request.env.cr directly", DeprecationWarning, stacklevel=2)
1854 return self.env.cr
1856 @cr.setter
1857 def cr(self, value):
1858 if value is None:
1859 raise NotImplementedError("Close the cursor instead.")
1860 raise ValueError("You cannot replace the cursor attached to the current request.")
1862 _cr = cr
1864 @functools.cached_property
1865 def best_lang(self):
1866 lang = self.httprequest.accept_languages.best
1867 if not lang:
1868 return None
1870 try:
1871 code, territory, _, _ = babel.core.parse_locale(lang, sep='-')
1872 if territory:
1873 lang = f'{code}_{territory}'
1874 else:
1875 lang = babel.core.LOCALE_ALIASES[code]
1876 return lang
1877 except (ValueError, KeyError):
1878 return None
1880 @functools.cached_property
1881 def cookies(self):
1882 cookies = werkzeug.datastructures.MultiDict(self.httprequest.cookies)
1883 if self.registry:
1884 self.registry['ir.http']._sanitize_cookies(cookies)
1885 return werkzeug.datastructures.ImmutableMultiDict(cookies)
1887 # =====================================================
1888 # Helpers
1889 # =====================================================
1890 def csrf_token(self, time_limit=None):
1891 """
1892 Generates and returns a CSRF token for the current session
1894 :param Optional[int] time_limit: the CSRF token should only be
1895 valid for the specified duration (in second), by default
1896 48h, ``None`` for the token to be valid as long as the
1897 current user's session is.
1898 :returns: ASCII token string
1899 :rtype: str
1900 """
1901 secret = self.env['ir.config_parameter'].sudo().get_param('database.secret')
1902 if not secret:
1903 raise ValueError("CSRF protection requires a configured database secret")
1905 # if no `time_limit` => distant 1y expiry so max_ts acts as salt, e.g. vs BREACH
1906 max_ts = int(time.time() + (time_limit or CSRF_TOKEN_SALT))
1907 msg = f'{self.session.sid[:STORED_SESSION_BYTES]}{max_ts}'.encode()
1909 hm = hmac.new(secret.encode('ascii'), msg, hashlib.sha1).hexdigest()
1910 return f'{hm}o{max_ts}'
1912 def validate_csrf(self, csrf):
1913 """
1914 Is the given csrf token valid ?
1916 :param str csrf: The token to validate.
1917 :returns: ``True`` when valid, ``False`` when not.
1918 :rtype: bool
1919 """
1920 if not csrf:
1921 return False
1923 secret = self.env['ir.config_parameter'].sudo().get_param('database.secret')
1924 if not secret:
1925 raise ValueError("CSRF protection requires a configured database secret")
1927 hm, _, max_ts = csrf.rpartition('o')
1928 msg = f'{self.session.sid[:STORED_SESSION_BYTES]}{max_ts}'.encode()
1930 if max_ts:
1931 try:
1932 if int(max_ts) < int(time.time()):
1933 return False
1934 except ValueError:
1935 return False
1937 hm_expected = hmac.new(secret.encode('ascii'), msg, hashlib.sha1).hexdigest()
1938 return consteq(hm, hm_expected)
1940 def default_context(self):
1941 return dict(get_default_session()['context'], lang=self.default_lang())
1943 def default_lang(self):
1944 """Returns default user language according to request specification
1946 :returns: Preferred language if specified or 'en_US'
1947 :rtype: str
1948 """
1949 return self.best_lang or DEFAULT_LANG
1951 def get_http_params(self):
1952 """
1953 Extract key=value pairs from the query string and the forms
1954 present in the body (both application/x-www-form-urlencoded and
1955 multipart/form-data).
1957 :returns: The merged key-value pairs.
1958 :rtype: dict
1959 """
1960 params = {
1961 **self.httprequest.args,
1962 **self.httprequest.form,
1963 **self.httprequest.files
1964 }
1965 return params
1967 def get_json_data(self):
1968 return json.loads(self.httprequest.get_data(as_text=True))
1970 def _get_profiler_context_manager(self):
1971 """
1972 Get a profiler when the profiling is enabled and the requested
1973 URL is profile-safe. Otherwise, get a context-manager that does
1974 nothing.
1975 """
1976 if self.session.get('profile_session') and self.db:
1977 if self.session['profile_expiration'] < str(datetime.now()):
1978 # avoid having session profiling for too long if user forgets to disable profiling
1979 self.session['profile_session'] = None
1980 _logger.warning("Profiling expiration reached, disabling profiling")
1981 elif 'set_profiling' in self.httprequest.path:
1982 _logger.debug("Profiling disabled on set_profiling route")
1983 elif self.httprequest.path.startswith('/websocket'):
1984 _logger.debug("Profiling disabled for websocket")
1985 elif odoo.evented:
1986 # only longpolling should be in a evented server, but this is an additional safety
1987 _logger.debug("Profiling disabled for evented server")
1988 else:
1989 try:
1990 return profiler.Profiler(
1991 db=self.db,
1992 description=self.httprequest.full_path,
1993 profile_session=self.session['profile_session'],
1994 collectors=self.session['profile_collectors'],
1995 params=self.session['profile_params'],
1996 )._get_cm_proxy()
1997 except Exception:
1998 _logger.exception("Failure during Profiler creation")
1999 self.session['profile_session'] = None
2001 return contextlib.nullcontext()
2003 def _inject_future_response(self, response):
2004 response.headers.extend(self.future_response.headers)
2005 return response
2007 def make_response(self, data, headers=None, cookies=None, status=200):
2008 """ Helper for non-HTML responses, or HTML responses with custom
2009 response headers or cookies.
2011 While handlers can just return the HTML markup of a page they want to
2012 send as a string if non-HTML data is returned they need to create a
2013 complete response object, or the returned data will not be correctly
2014 interpreted by the clients.
2016 :param str data: response body
2017 :param int status: http status code
2018 :param headers: HTTP headers to set on the response
2019 :type headers: ``[(name, value)]``
2020 :param collections.abc.Mapping cookies: cookies to set on the client
2021 :returns: a response object.
2022 :rtype: :class:`~odoo.http.Response`
2023 """
2024 response = Response(data, status=status, headers=headers)
2025 if cookies:
2026 for k, v in cookies.items():
2027 response.set_cookie(k, v)
2028 return response
2030 def make_json_response(self, data, headers=None, cookies=None, status=200):
2031 """ Helper for JSON responses, it json-serializes ``data`` and
2032 sets the Content-Type header accordingly if none is provided.
2034 :param data: the data that will be json-serialized into the response body
2035 :param int status: http status code
2036 :param List[(str, str)] headers: HTTP headers to set on the response
2037 :param collections.abc.Mapping cookies: cookies to set on the client
2038 :rtype: :class:`~odoo.http.Response`
2039 """
2040 data = json.dumps(data, ensure_ascii=False, default=json_default)
2042 headers = werkzeug.datastructures.Headers(headers)
2043 headers['Content-Length'] = len(data)
2044 if 'Content-Type' not in headers:
2045 headers['Content-Type'] = 'application/json; charset=utf-8'
2047 return self.make_response(data, headers.to_wsgi_list(), cookies, status)
2049 def not_found(self, description=None):
2050 """ Shortcut for a `HTTP 404
2051 <http://tools.ietf.org/html/rfc7231#section-6.5.4>`_ (Not Found)
2052 response
2053 """
2054 return NotFound(description)
2056 def redirect(self, location, code=303, local=True):
2057 # compatibility, Werkzeug support URL as location
2058 if isinstance(location, URL):
2059 location = location.to_url()
2060 if local:
2061 location = '/' + url_parse(location).replace(scheme='', netloc='').to_url().lstrip('/\\')
2062 if self.db:
2063 return self.env['ir.http']._redirect(location, code)
2064 return werkzeug.utils.redirect(location, code, Response=Response)
2066 def redirect_query(self, location, query=None, code=303, local=True):
2067 if query:
2068 location += '?' + url_encode(query)
2069 return self.redirect(location, code=code, local=local)
2071 def render(self, template, qcontext=None, lazy=True, **kw):
2072 """ Lazy render of a QWeb template.
2074 The actual rendering of the given template will occur at then end of
2075 the dispatching. Meanwhile, the template and/or qcontext can be
2076 altered or even replaced by a static response.
2078 :param str template: template to render
2079 :param dict qcontext: Rendering context to use
2080 :param bool lazy: whether the template rendering should be deferred
2081 until the last possible moment
2082 :param dict kw: forwarded to werkzeug's Response object
2083 """
2084 response = Response(template=template, qcontext=qcontext, **kw)
2085 if not lazy:
2086 return response.render()
2087 return response
2089 def reroute(self, path, query_string=None):
2090 """
2091 Rewrite the current request URL using the new path and query
2092 string. This act as a light redirection, it does not return a
2093 3xx responses to the browser but still change the current URL.
2094 """
2095 # WSGI encoding dance https://peps.python.org/pep-3333/#unicode-issues
2096 if isinstance(path, str):
2097 path = path.encode('utf-8')
2098 path = path.decode('latin1', 'replace')
2100 if query_string is None:
2101 query_string = request.httprequest.environ['QUERY_STRING']
2103 # Change the WSGI environment
2104 environ = self.httprequest._HTTPRequest__environ.copy()
2105 environ['PATH_INFO'] = path
2106 environ['QUERY_STRING'] = query_string
2107 environ['RAW_URI'] = f'{path}?{query_string}'
2108 # REQUEST_URI left as-is so it still contains the original URI
2110 # Create and expose a new request from the modified WSGI env
2111 httprequest = HTTPRequest(environ)
2112 threading.current_thread().url = httprequest.url
2113 self.httprequest = httprequest
2115 def _save_session(self, env=None):
2116 """
2117 Save a modified session on disk.
2119 :param env: an environment to compute the session token.
2120 MUST be left ``None`` (in which case it uses the request's
2121 env) UNLESS the database changed.
2122 """
2123 sess = self.session
2124 if env is None:
2125 env = self.env
2127 if not sess.can_save:
2128 return
2130 if sess.should_rotate:
2131 root.session_store.rotate(sess, env) # it saves
2132 elif (
2133 sess.uid
2134 and time.time() >= sess['create_time'] + SESSION_ROTATION_INTERVAL
2135 and request.httprequest.path not in SESSION_ROTATION_EXCLUDED_PATHS
2136 ):
2137 root.session_store.rotate(sess, env, True)
2138 elif sess.is_dirty:
2139 root.session_store.save(sess)
2141 cookie_sid = self.cookies.get('session_id')
2142 if sess.is_dirty or cookie_sid != sess.sid:
2143 self.future_response.set_cookie(
2144 'session_id',
2145 sess.sid,
2146 max_age=get_session_max_inactivity(env),
2147 httponly=True
2148 )
2150 def _set_request_dispatcher(self, rule):
2151 routing = rule.endpoint.routing
2152 dispatcher_cls = _dispatchers[routing['type']]
2153 if (not is_cors_preflight(self, rule.endpoint)
2154 and not dispatcher_cls.is_compatible_with(self)):
2155 compatible_dispatchers = [
2156 disp.routing_type
2157 for disp in _dispatchers.values()
2158 if disp.is_compatible_with(self)
2159 ]
2160 e = (f"Request inferred type is compatible with {compatible_dispatchers} "
2161 f"but {routing['routes'][0]!r} is type={routing['type']!r}.\n\n"
2162 "Please verify the Content-Type request header and try again.")
2163 # werkzeug doesn't let us add headers to UnsupportedMediaType
2164 # so use the following (ugly) to still achieve what we want
2165 res = UnsupportedMediaType(e).get_response()
2166 res.headers['Accept'] = ', '.join(dispatcher_cls.mimetypes)
2167 raise UnsupportedMediaType(response=res)
2168 self.dispatcher = dispatcher_cls(self)
2170 # =====================================================
2171 # Routing
2172 # =====================================================
2173 def _serve_static(self):
2174 """ Serve a static file from the file system. """
2175 module, _, path = self.httprequest.path[1:].partition('/static/')
2176 try:
2177 directory = root.static_path(module)
2178 if not directory:
2179 raise NotFound(f'Module "{module}" not found.\n')
2180 filepath = werkzeug.security.safe_join(directory, path)
2181 debug = (
2182 'assets' in self.session.debug and
2183 ' wkhtmltopdf ' not in self.httprequest.user_agent.string
2184 )
2185 res = Stream.from_path(filepath, public=True).get_response(
2186 max_age=0 if debug else STATIC_CACHE,
2187 content_security_policy=None,
2188 )
2189 root.set_csp(res)
2190 return res
2191 except OSError: # cover both missing file and invalid permissions
2192 raise NotFound(f'File "{path}" not found in module {module}.\n')
2194 def _serve_nodb(self):
2195 """
2196 Dispatch the request to its matching controller in a
2197 database-free environment.
2198 """
2199 try:
2200 router = root.nodb_routing_map.bind_to_environ(self.httprequest.environ)
2201 try:
2202 rule, args = router.match(return_rule=True)
2203 except NotFound as exc:
2204 exc.response = Response(NOT_FOUND_NODB, status=exc.code, headers=[
2205 ('Content-Type', 'text/html; charset=utf-8'),
2206 ])
2207 raise
2208 self._set_request_dispatcher(rule)
2209 self.dispatcher.pre_dispatch(rule, args)
2210 response = self.dispatcher.dispatch(rule.endpoint, args)
2211 self.dispatcher.post_dispatch(response)
2212 return response
2213 except HTTPException as exc:
2214 if exc.code is not None:
2215 raise
2216 # Valid response returned via werkzeug.exceptions.abort
2217 response = exc.get_response()
2218 HttpDispatcher(self).post_dispatch(response)
2219 return response
2221 def _serve_db(self):
2222 """ Load the ORM and use it to process the request. """
2223 # reuse the same cursor for building, checking the registry, for
2224 # matching the controller endpoint and serving the data
2225 cr = None
2226 try:
2227 # get the registry and cursor (RO)
2228 try:
2229 registry = Registry(self.db)
2230 cr = registry.cursor(readonly=True)
2231 self.registry = registry.check_signaling(cr)
2232 except (AttributeError, psycopg2.OperationalError, psycopg2.ProgrammingError) as e:
2233 raise RegistryError(f"Cannot get registry {self.db}") from e
2234 threading.current_thread().dbname = self.registry.db_name
2236 # find the controller endpoint to use
2237 self.env = odoo.api.Environment(cr, self.session.uid, self.session.context)
2238 try:
2239 rule, args = self.registry['ir.http']._match(self.httprequest.path)
2240 except NotFound as not_found_exc:
2241 # no controller endpoint matched -> fallback or 404
2242 serve_func = functools.partial(self._serve_ir_http_fallback, not_found_exc)
2243 readonly = True
2244 else:
2245 # a controller endpoint matched -> dispatch it the request
2246 self._set_request_dispatcher(rule)
2247 serve_func = functools.partial(self._serve_ir_http, rule, args)
2248 readonly = rule.endpoint.routing['readonly']
2249 if callable(readonly):
2250 readonly = readonly(rule.endpoint.func.__self__, rule, args)
2252 # keep on using the RO cursor when a readonly route matched,
2253 # and for serve fallback
2254 if readonly and cr.readonly:
2255 threading.current_thread().cursor_mode = 'ro'
2256 try:
2257 return service_model.retrying(serve_func, env=self.env)
2258 except psycopg2.errors.ReadOnlySqlTransaction as exc:
2259 # although the controller is marked read-only, it
2260 # attempted a write operation, try again using a
2261 # read/write cursor
2262 _logger.warning("%s, retrying with a read/write cursor", exc.args[0].rstrip(), exc_info=True)
2263 threading.current_thread().cursor_mode = 'ro->rw'
2264 except Exception as exc: # noqa: BLE001
2265 raise self._update_served_exception(exc)
2266 else:
2267 threading.current_thread().cursor_mode = 'rw'
2269 # we must use a RW cursor when a read/write route matched, or
2270 # there was a ReadOnlySqlTransaction error
2271 if cr.readonly:
2272 cr.close()
2273 cr = self.env.registry.cursor()
2274 else:
2275 # the cursor is already a RW cursor, start a new transaction
2276 # that will avoid repeatable read serialization errors because
2277 # check signaling is not done in `retrying` and that function
2278 # would just succeed the second time
2279 cr.rollback()
2280 assert not cr.readonly
2281 self.env = self.env(cr=cr)
2282 try:
2283 return service_model.retrying(serve_func, env=self.env)
2284 except Exception as exc: # noqa: BLE001
2285 raise self._update_served_exception(exc)
2286 except HTTPException as exc:
2287 if exc.code is not None:
2288 raise
2289 # Valid response returned via werkzeug.exceptions.abort
2290 response = exc.get_response()
2291 HttpDispatcher(self).post_dispatch(response)
2292 return response
2293 finally:
2294 self.env = None
2295 if cr is not None:
2296 cr.close()
2298 def _update_served_exception(self, exc):
2299 if isinstance(exc, HTTPException) and exc.code is None:
2300 return exc # bubble up to _serve_db
2301 if (
2302 'werkzeug' in config['dev_mode']
2303 and self.dispatcher.routing_type != JsonRPCDispatcher.routing_type
2304 ):
2305 return exc # bubble up to werkzeug.debug.DebuggedApplication
2306 if not hasattr(exc, 'error_response'):
2307 if isinstance(exc, AccessDenied):
2308 exc.suppress_traceback()
2309 exc.error_response = self.registry['ir.http']._handle_error(exc)
2310 return exc
2312 def _serve_ir_http_fallback(self, not_found):
2313 """
2314 Called when no controller match the request path. Delegate to
2315 ``ir.http._serve_fallback`` to give modules the opportunity to
2316 find an alternative way to serve the request. In case no module
2317 provided a response, a generic 404 - Not Found page is returned.
2318 """
2319 self.params = self.get_http_params()
2320 self.registry['ir.http']._auth_method_public()
2321 response = self.registry['ir.http']._serve_fallback()
2322 if response:
2323 self.registry['ir.http']._post_dispatch(response)
2324 return response
2326 no_fallback = NotFound()
2327 no_fallback.__context__ = not_found # During handling of {not_found}, {no_fallback} occurred:
2328 no_fallback.error_response = self.registry['ir.http']._handle_error(no_fallback)
2329 raise no_fallback
2331 def _serve_ir_http(self, rule, args):
2332 """
2333 Called when a controller match the request path. Delegate to
2334 ``ir.http`` to serve a response.
2335 """
2336 self.registry['ir.http']._authenticate(rule.endpoint)
2337 self.registry['ir.http']._pre_dispatch(rule, args)
2338 response = self.dispatcher.dispatch(rule.endpoint, args)
2339 self.registry['ir.http']._post_dispatch(response)
2340 return response
2343# =========================================================
2344# Core type-specialized dispatchers
2345# =========================================================
2347_dispatchers = {}
2349class Dispatcher(ABC):
2350 routing_type: str
2351 mimetypes: collections.abc.Collection[str] = ()
2353 @classmethod
2354 def __init_subclass__(cls):
2355 super().__init_subclass__()
2356 _dispatchers[cls.routing_type] = cls
2358 def __init__(self, request):
2359 self.request = request
2361 @classmethod
2362 @abstractmethod
2363 def is_compatible_with(cls, request):
2364 """
2365 Determine if the current request is compatible with this
2366 dispatcher.
2367 """
2369 def pre_dispatch(self, rule, args):
2370 """
2371 Prepare the system before dispatching the request to its
2372 controller. This method is often overridden in ir.http to
2373 extract some info from the request query-string or headers and
2374 to save them in the session or in the context.
2375 """
2376 routing = rule.endpoint.routing
2377 self.request.session.can_save &= routing.get('save_session', True)
2379 set_header = self.request.future_response.headers.set
2380 cors = routing.get('cors')
2381 if cors:
2382 set_header('Access-Control-Allow-Origin', cors)
2383 set_header('Access-Control-Allow-Methods', (
2384 'POST' if routing['type'] == JsonRPCDispatcher.routing_type
2385 else ', '.join(routing['methods'] or ['GET', 'POST'])
2386 ))
2388 if cors and self.request.httprequest.method == 'OPTIONS':
2389 set_header('Access-Control-Max-Age', CORS_MAX_AGE)
2390 set_header('Access-Control-Allow-Headers',
2391 'Origin, X-Requested-With, Content-Type, Accept, Authorization')
2392 werkzeug.exceptions.abort(Response(status=204))
2394 if 'max_content_length' in routing:
2395 max_content_length = routing['max_content_length']
2396 if callable(max_content_length):
2397 max_content_length = max_content_length(rule.endpoint.func.__self__)
2398 self.request.httprequest.max_content_length = max_content_length
2400 @abstractmethod
2401 def dispatch(self, endpoint, args):
2402 """
2403 Extract the params from the request's body and call the
2404 endpoint. While it is preferred to override ir.http._pre_dispatch
2405 and ir.http._post_dispatch, this method can be override to have
2406 a tight control over the dispatching.
2407 """
2409 def post_dispatch(self, response):
2410 """
2411 Manipulate the HTTP response to inject various headers, also
2412 save the session when it is dirty.
2413 """
2414 self.request._save_session()
2415 self.request._inject_future_response(response)
2416 root.set_csp(response)
2418 @abstractmethod
2419 def handle_error(self, exc: Exception) -> collections.abc.Callable:
2420 """
2421 Transform the exception into a valid HTTP response. Called upon
2422 any exception while serving a request.
2423 """
2426class HttpDispatcher(Dispatcher):
2427 routing_type = 'http'
2429 mimetypes = ('application/x-www-form-urlencoded', 'multipart/form-data', '*/*')
2431 @classmethod
2432 def is_compatible_with(cls, request):
2433 return True
2435 def dispatch(self, endpoint, args):
2436 """
2437 Perform http-related actions such as deserializing the request
2438 body and query-string and checking cors/csrf while dispatching a
2439 request to a ``type='http'`` route.
2441 See :meth:`~odoo.http.Response.load` method for the compatible
2442 endpoint return types.
2443 """
2444 self.request.params = dict(self.request.get_http_params(), **args)
2446 # Check for CSRF token for relevant requests
2447 if self.request.httprequest.method not in SAFE_HTTP_METHODS and endpoint.routing.get('csrf', True):
2448 if not self.request.db:
2449 return self.request.redirect('/web/database/selector')
2451 token = self.request.params.pop('csrf_token', None)
2452 if not self.request.validate_csrf(token):
2453 if token is not None:
2454 _logger.warning("CSRF validation failed on path '%s'", self.request.httprequest.path)
2455 else:
2456 _logger.warning(MISSING_CSRF_WARNING, request.httprequest.path)
2457 raise werkzeug.exceptions.BadRequest('Session expired (invalid CSRF token)')
2459 if self.request.db:
2460 return self.request.registry['ir.http']._dispatch(endpoint)
2461 else:
2462 return endpoint(**self.request.params)
2464 def handle_error(self, exc: Exception) -> collections.abc.Callable:
2465 """
2466 Handle any exception that occurred while dispatching a request
2467 to a `type='http'` route. Also handle exceptions that occurred
2468 when no route matched the request path, when no fallback page
2469 could be delivered and that the request ``Content-Type`` was not
2470 json.
2472 :param Exception exc: the exception that occurred.
2473 :returns: a WSGI application
2474 """
2475 if isinstance(exc, SessionExpiredException):
2476 session = self.request.session
2477 was_connected = session.uid is not None
2478 session.logout(keep_db=True)
2479 response = self.request.redirect_query('/web/login', {'redirect': self.request.httprequest.full_path})
2480 if was_connected:
2481 root.session_store.rotate(session, self.request.env)
2482 response.set_cookie('session_id', session.sid, max_age=get_session_max_inactivity(self.request.env), httponly=True)
2483 return response
2485 if isinstance(exc, HTTPException):
2486 return exc
2488 if isinstance(exc, UserError):
2489 try:
2490 return werkzeug_default_exceptions[exc.http_status](exc.args[0])
2491 except (KeyError, AttributeError):
2492 return UnprocessableEntity(exc.args[0])
2494 return InternalServerError()
2497class JsonRPCDispatcher(Dispatcher):
2498 routing_type = 'jsonrpc'
2499 mimetypes = ('application/json', 'application/json-rpc')
2501 def __init__(self, request):
2502 super().__init__(request)
2503 self.jsonrequest = {}
2504 self.request_id = None
2506 @classmethod
2507 def is_compatible_with(cls, request):
2508 return request.httprequest.mimetype in cls.mimetypes
2510 def dispatch(self, endpoint, args):
2511 """
2512 `JSON-RPC 2 <http://www.jsonrpc.org/specification>`_ over HTTP.
2514 Our implementation differs from the specification on two points:
2516 1. The ``method`` member of the JSON-RPC request payload is
2517 ignored as the HTTP path is already used to route the request
2518 to the controller.
2519 2. We only support parameter structures by-name, i.e. the
2520 ``params`` member of the JSON-RPC request payload MUST be a
2521 JSON Object and not a JSON Array.
2523 In addition, it is possible to pass a context that replaces
2524 the session context via a special ``context`` argument that is
2525 removed prior to calling the endpoint.
2527 Successful request::
2529 --> {"jsonrpc": "2.0", "method": "call", "params": {"arg1": "val1" }, "id": null}
2531 <-- {"jsonrpc": "2.0", "result": { "res1": "val1" }, "id": null}
2533 Request producing a error::
2535 --> {"jsonrpc": "2.0", "method": "call", "params": {"arg1": "val1" }, "id": null}
2537 <-- {"jsonrpc": "2.0", "error": {"code": 1, "message": "End user error message.", "data": {"code": "codestring", "debug": "traceback" } }, "id": null}
2539 """
2540 try:
2541 self.jsonrequest = self.request.get_json_data()
2542 self.request_id = self.jsonrequest.get('id')
2543 except ValueError:
2544 # must use abort+Response to bypass handle_error
2545 werkzeug.exceptions.abort(Response("Invalid JSON data", status=400))
2546 except AttributeError:
2547 # must use abort+Response to bypass handle_error
2548 werkzeug.exceptions.abort(Response("Invalid JSON-RPC data", status=400))
2550 self.request.params = dict(self.jsonrequest.get('params', {}), **args)
2552 if self.request.db:
2553 result = self.request.registry['ir.http']._dispatch(endpoint)
2554 else:
2555 result = endpoint(**self.request.params)
2556 return self._response(result)
2558 def handle_error(self, exc: Exception) -> collections.abc.Callable:
2559 """
2560 Handle any exception that occurred while dispatching a request to
2561 a `type='jsonrpc'` route. Also handle exceptions that occurred when
2562 no route matched the request path, that no fallback page could
2563 be delivered and that the request ``Content-Type`` was json.
2565 :param exc: the exception that occurred.
2566 :returns: a WSGI application
2567 """
2568 error = {
2569 'code': 0, # we don't care of this code
2570 'message': "Odoo Server Error",
2571 'data': serialize_exception(exc),
2572 }
2573 if isinstance(exc, NotFound):
2574 error['code'] = 404
2575 error['message'] = "404: Not Found"
2576 elif isinstance(exc, SessionExpiredException):
2577 error['code'] = 100
2578 error['message'] = "Odoo Session Expired"
2580 return self._response(error=error)
2582 def _response(self, result=None, error=None):
2583 response = {'jsonrpc': '2.0', 'id': self.request_id}
2584 if error is not None:
2585 response['error'] = error
2586 if result is not None:
2587 response['result'] = result
2589 return self.request.make_json_response(response)
2592class Json2Dispatcher(Dispatcher):
2593 routing_type = 'json2'
2594 mimetypes = ('application/json',)
2596 def __init__(self, request):
2597 super().__init__(request)
2598 self.jsonrequest = None
2600 @classmethod
2601 def is_compatible_with(cls, request):
2602 return request.httprequest.mimetype in cls.mimetypes or not request.httprequest.content_length
2604 def dispatch(self, endpoint, args):
2605 # "args" are the path parameters, "id" in /web/image/<id>
2606 if self.request.httprequest.content_length:
2607 try:
2608 self.jsonrequest = self.request.get_json_data()
2609 except ValueError as exc:
2610 e = f"could not parse the body as json: {exc.args[0]}"
2611 raise werkzeug.exceptions.BadRequest(e) from exc
2612 try:
2613 self.request.params = self.jsonrequest | args
2614 except TypeError:
2615 self.request.params = dict(args) # make a copy
2617 if self.request.db:
2618 result = self.request.registry['ir.http']._dispatch(endpoint)
2619 else:
2620 result = endpoint(**self.request.params)
2621 if isinstance(result, Response):
2622 return result
2623 return self.request.make_json_response(result)
2625 def handle_error(self, exc: Exception) -> collections.abc.Callable:
2626 if isinstance(exc, HTTPException) and exc.response:
2627 return exc.response
2629 headers = None
2630 if isinstance(exc, (UserError, SessionExpiredException)):
2631 status = exc.http_status
2632 body = serialize_exception(exc)
2633 elif isinstance(exc, HTTPException):
2634 status = exc.code
2635 body = serialize_exception(
2636 exc,
2637 message=exc.description,
2638 arguments=(exc.description, exc.code),
2639 )
2640 # strip Content-Type but keep the remaining headers
2641 ct, *headers = exc.get_headers()
2642 assert ct == ('Content-Type', 'text/html; charset=utf-8')
2643 else:
2644 status = HTTPStatus.INTERNAL_SERVER_ERROR
2645 body = serialize_exception(exc)
2647 return self.request.make_json_response(body, headers=headers, status=status)
2650# =========================================================
2651# WSGI Entry Point
2652# =========================================================
2654class Application:
2655 """ Odoo WSGI application """
2656 # See also: https://www.python.org/dev/peps/pep-3333
2658 def initialize(self):
2659 """
2660 Initialize the application.
2662 This is to be called when setting up a WSGI application after
2663 initializing the configuration values.
2664 """
2665 module_manager.initialize_sys_path()
2666 from odoo.service.server import load_server_wide_modules # noqa: PLC0415
2667 load_server_wide_modules()
2669 def static_path(self, module_name: str) -> str | None:
2670 """
2671 Map module names to their absolute ``static`` path on the file
2672 system.
2673 """
2674 manifest = module_manager.Manifest.for_addon(module_name, display_warning=False)
2675 return manifest.static_path if manifest is not None else None
2677 def get_static_file(self, url, host=''):
2678 """
2679 Get the full-path of the file if the url resolves to a local
2680 static file, otherwise return None.
2682 Without the second host parameters, ``url`` must be an absolute
2683 path, others URLs are considered faulty.
2685 With the second host parameters, ``url`` can also be a full URI
2686 and the authority found in the URL (if any) is validated against
2687 the given ``host``.
2688 """
2690 netloc, path = urlparse(url)[1:3]
2691 try:
2692 path_netloc, module, static, resource = path.split('/', 3)
2693 except ValueError:
2694 return None
2696 if ((netloc and netloc != host) or (path_netloc and path_netloc != host)):
2697 return None
2699 if not (static == 'static' and resource):
2700 return None
2702 static_path = self.static_path(module)
2703 if not static_path:
2704 return None
2706 try:
2707 return file_path(opj(static_path, resource))
2708 except FileNotFoundError:
2709 return None
2711 @functools.cached_property
2712 def nodb_routing_map(self):
2713 nodb_routing_map = werkzeug.routing.Map(strict_slashes=False, converters=None)
2714 for url, endpoint in _generate_routing_rules([''] + config['server_wide_modules'], nodb_only=True):
2715 routing = submap(endpoint.routing, ROUTING_KEYS)
2716 if routing['methods'] is not None and 'OPTIONS' not in routing['methods']:
2717 routing['methods'] = [*routing['methods'], 'OPTIONS']
2718 rule = werkzeug.routing.Rule(url, endpoint=endpoint, **routing)
2719 rule.merge_slashes = False
2720 nodb_routing_map.add(rule)
2722 return nodb_routing_map
2724 @functools.cached_property
2725 def session_store(self):
2726 path = odoo.tools.config.session_dir
2727 _logger.debug('HTTP sessions stored in: %s', path)
2728 return FilesystemSessionStore(path, session_class=Session, renew_missing=True)
2730 def get_db_router(self, db):
2731 if not db:
2732 return self.nodb_routing_map
2733 return request.env['ir.http'].routing_map()
2735 @functools.cached_property
2736 def geoip_city_db(self):
2737 try:
2738 return geoip2.database.Reader(config['geoip_city_db'])
2739 except (OSError, maxminddb.InvalidDatabaseError):
2740 _logger.debug(
2741 "Couldn't load Geoip City file at %s. IP Resolver disabled.",
2742 config['geoip_city_db'], exc_info=True
2743 )
2744 raise
2746 @functools.cached_property
2747 def geoip_country_db(self):
2748 try:
2749 return geoip2.database.Reader(config['geoip_country_db'])
2750 except (OSError, maxminddb.InvalidDatabaseError) as exc:
2751 _logger.debug("Couldn't load Geoip Country file (%s). Fallbacks on Geoip City.", exc,)
2752 raise
2754 def set_csp(self, response):
2755 headers = response.headers
2756 headers['X-Content-Type-Options'] = 'nosniff'
2758 if 'Content-Security-Policy' in headers:
2759 return
2761 if not headers.get('Content-Type', '').startswith('image/'):
2762 return
2764 headers['Content-Security-Policy'] = "default-src 'none'"
2766 def __call__(self, environ, start_response):
2767 """
2768 WSGI application entry point.
2770 :param dict environ: container for CGI environment variables
2771 such as the request HTTP headers, the source IP address and
2772 the body as an io file.
2773 :param callable start_response: function provided by the WSGI
2774 server that this application must call in order to send the
2775 HTTP response status line and the response headers.
2776 """
2777 current_thread = threading.current_thread()
2778 current_thread.query_count = 0
2779 current_thread.query_time = 0
2780 current_thread.perf_t0 = real_time()
2781 current_thread.cursor_mode = None
2782 if hasattr(current_thread, 'dbname'):
2783 del current_thread.dbname
2784 if hasattr(current_thread, 'uid'):
2785 del current_thread.uid
2786 thread_local.rpc_model_method = ''
2788 if odoo.tools.config['proxy_mode'] and environ.get("HTTP_X_FORWARDED_HOST"):
2789 # The ProxyFix middleware has a side effect of updating the
2790 # environ, see https://github.com/pallets/werkzeug/pull/2184
2791 def fake_app(environ, start_response):
2792 return []
2793 def fake_start_response(status, headers):
2794 return
2795 ProxyFix(fake_app)(environ, fake_start_response)
2797 with HTTPRequest(environ) as httprequest:
2798 request = Request(httprequest)
2799 _request_stack.push(request)
2801 try:
2802 request._post_init()
2803 current_thread.url = httprequest.url
2805 if self.get_static_file(httprequest.path):
2806 response = request._serve_static()
2807 elif request.db:
2808 try:
2809 with request._get_profiler_context_manager():
2810 response = request._serve_db()
2811 except RegistryError as e:
2812 _logger.warning("Database or registry unusable, trying without", exc_info=e.__cause__)
2813 request.db = None
2814 request.session.logout()
2815 if (httprequest.path.startswith('/odoo/')
2816 or httprequest.path in (
2817 '/odoo', '/web', '/web/login', '/test_http/ensure_db',
2818 )):
2819 # ensure_db() protected routes, remove ?db= from the query string
2820 args_nodb = request.httprequest.args.copy()
2821 args_nodb.pop('db', None)
2822 request.reroute(httprequest.path, url_encode(args_nodb))
2823 response = request._serve_nodb()
2824 else:
2825 response = request._serve_nodb()
2826 return response(environ, start_response)
2828 except Exception as exc:
2829 # Logs the error here so the traceback starts with ``__call__``.
2830 if hasattr(exc, 'loglevel'):
2831 _logger.log(exc.loglevel, exc, exc_info=getattr(exc, 'exc_info', None))
2832 elif isinstance(exc, HTTPException):
2833 pass
2834 elif isinstance(exc, SessionExpiredException):
2835 _logger.info(exc)
2836 elif isinstance(exc, AccessError):
2837 _logger.warning(exc, exc_info='access' in config['dev_mode'])
2838 elif isinstance(exc, UserError):
2839 _logger.warning(exc)
2840 else:
2841 _logger.exception("Exception during request handling.")
2843 # Ensure there is always a WSGI handler attached to the exception.
2844 if not hasattr(exc, 'error_response'):
2845 if isinstance(exc, AccessDenied):
2846 exc.suppress_traceback()
2847 exc.error_response = request.dispatcher.handle_error(exc)
2849 return exc.error_response(environ, start_response)
2851 finally:
2852 _request_stack.pop()
2855root = Application()