|
- # Part of Odoo. See LICENSE file for full copyright and licensing details.
- r"""\
- Odoo HTTP layer / WSGI application
-
- The main duty of this module is to prepare and dispatch all http
- requests to their corresponding controllers: from a raw http request
- arriving on the WSGI entrypoint to a :class:`~http.Request`: arriving at
- a module controller with a fully setup ORM available.
-
- Application developers mostly know this module thanks to the
- :class:`~odoo.http.Controller`: class and its companion the
- :func:`~odoo.http.route`: method decorator. Together they are used to
- register methods responsible of delivering web content to matching URLS.
-
- Those two are only the tip of the iceberg, below is a call graph that
- shows the various processing layers each request passes through before
- ending at the @route decorated endpoint. Hopefully, this call graph and
- the attached function descriptions will help you understand this module.
-
- Here be dragons:
-
- Application.__call__
- if path is like '/<module>/static/<path>':
- Request._serve_static
-
- elif not request.db:
- Request._serve_nodb
- App.nodb_routing_map.match
- Dispatcher.pre_dispatch
- Dispatcher.dispatch
- route_wrapper
- endpoint
- Dispatcher.post_dispatch
-
- else:
- Request._serve_db
- env['ir.http']._match
-
- if not match:
- Request._transactioning
- model.retrying
- env['ir.http']._serve_fallback
- env['ir.http']._post_dispatch
- else:
- Request._transactioning
- model.retrying
- env['ir.http']._authenticate
- env['ir.http']._pre_dispatch
- Dispatcher.pre_dispatch
- Dispatcher.dispatch
- env['ir.http']._dispatch
- route_wrapper
- endpoint
- env['ir.http']._post_dispatch
-
- Application.__call__
- WSGI entry point, it sanitizes the request, it wraps it in a werkzeug
- request and itself in an Odoo http request. The Odoo http request is
- exposed at ``http.request`` then it is forwarded to either
- ``_serve_static``, ``_serve_nodb`` or ``_serve_db`` depending on the
- request path and the presence of a database. It is also responsible of
- ensuring any error is properly logged and encapsuled in a HTTP error
- response.
-
- Request._serve_static
- Handle all requests to ``/<module>/static/<asset>`` paths, open the
- underlying file on the filesystem and stream it via
- :meth:``Request.send_file``
-
- Request._serve_nodb
- Handle requests to ``@route(auth='none')`` endpoints when the user is
- not connected to a database. It performs limited operations, just
- matching the auth='none' endpoint using the request path and then it
- delegates to Dispatcher.
-
- Request._serve_db
- Handle all requests that are not static when it is possible to connect
- to a database. It opens a registry on the database and then delegates
- most of the effort the the ``ir.http`` abstract model. This model acts
- as a module-aware middleware, its implementation in ``base`` is merely
- more than just delegating to Dispatcher.
-
- Request._transactioning & service.model.retrying
- Manage the cursor, the environment and exceptions that occured while
- executing the underlying function. They recover from various
- exceptions such as serialization errors and writes in read-only
- transactions. They catches all other exceptions and attach a http
- response to them (e.g. 500 - Internal Server Error)
-
- ir.http._match
- Match the controller endpoint that correspond to the request path.
- Beware that there is an important override for portal and website
- inside of the ``http_routing`` module.
-
- ir.http._serve_fallback
- Find alternative ways to serve a request when its path does not match
- any controller. The path could be matching an attachment URL, a blog
- page, etc.
-
- ir.http._authenticate
- Ensure the user on the current environment fulfill the requirement of
- ``@route(auth=...)``. Using the ORM outside of abstract models is
- unsafe prior of calling this function.
-
- ir.http._pre_dispatch/Dispatcher.pre_dispatch
- Prepare the system the handle the current request, often used to save
- some extra query-string parameters in the session (e.g. ?debug=1)
-
- ir.http._dispatch/Dispatcher.dispatch
- Deserialize the HTTP request body into ``request.params`` according to
- @route(type=...), call the controller endpoint, serialize its return
- value into an HTTP Response object.
-
- ir.http._post_dispatch/Dispatcher.post_dispatch
- Post process the response returned by the controller endpoint. Used to
- inject various headers such as Content-Security-Policy.
-
- ir.http._handle_error
- Not present in the call-graph, is called for un-managed exceptions (SE
- or RO) that occured inside of ``Request._transactioning``. It returns
- a http response that wraps the error that occured.
-
- route_wrapper, closure of the http.route decorator
- Sanitize the request parameters, call the route endpoint and
- optionally coerce the endpoint result.
-
- endpoint
- The @route(...) decorated controller method.
- """
-
- import base64
- import collections
- import collections.abc
- import contextlib
- import functools
- import glob
- import hashlib
- import hmac
- import inspect
- import json
- import logging
- import mimetypes
- import os
- import re
- import threading
- import time
- import traceback
- import warnings
- from abc import ABC, abstractmethod
- from datetime import datetime, timedelta
- from hashlib import sha512
- from io import BytesIO
- from os.path import join as opj
- from pathlib import Path
- from urllib.parse import urlparse
- from zlib import adler32
-
- import babel.core
-
- try:
- import geoip2.database
- import geoip2.models
- import geoip2.errors
- except ImportError:
- geoip2 = None
-
- try:
- import maxminddb
- except ImportError:
- maxminddb = None
-
- import psycopg2
- import werkzeug.datastructures
- import werkzeug.exceptions
- import werkzeug.local
- import werkzeug.routing
- import werkzeug.security
- import werkzeug.wrappers
- import werkzeug.wsgi
- from werkzeug.urls import URL, url_parse, url_encode, url_quote
- from werkzeug.exceptions import (HTTPException, BadRequest, Forbidden,
- NotFound, InternalServerError)
- try:
- from werkzeug.middleware.proxy_fix import ProxyFix as ProxyFix_
- ProxyFix = functools.partial(ProxyFix_, x_for=1, x_proto=1, x_host=1)
- except ImportError:
- from werkzeug.contrib.fixers import ProxyFix
-
- try:
- from werkzeug.utils import send_file as _send_file
- except ImportError:
- from .tools._vendor.send_file import send_file as _send_file
-
- import odoo
- from .exceptions import UserError, AccessError, AccessDenied
- from .modules.module import get_manifest
- from .modules.registry import Registry
- from .service import security, model as service_model
- from .tools import (config, consteq, file_path, get_lang, json_default,
- parse_version, profiler, unique, exception_to_unicode)
- from .tools.func import filter_kwargs, lazy_property
- from .tools.misc import submap
- from .tools._vendor import sessions
- from .tools._vendor.useragents import UserAgent
-
-
- _logger = logging.getLogger(__name__)
-
-
- # =========================================================
- # Const
- # =========================================================
-
- # The validity duration of a preflight response, one day.
- CORS_MAX_AGE = 60 * 60 * 24
-
- # The HTTP methods that do not require a CSRF validation.
- CSRF_FREE_METHODS = ('GET', 'HEAD', 'OPTIONS', 'TRACE')
-
- # The default csrf token lifetime, a salt against BREACH, one year
- CSRF_TOKEN_SALT = 60 * 60 * 24 * 365
-
- # The default lang to use when the browser doesn't specify it
- DEFAULT_LANG = 'en_US'
-
- # The dictionary to initialise a new session with.
- def get_default_session():
- return {
- 'context': {}, # 'lang': request.default_lang() # must be set at runtime
- 'db': None,
- 'debug': '',
- 'login': None,
- 'uid': None,
- 'session_token': None,
- '_trace': [],
- }
-
- DEFAULT_MAX_CONTENT_LENGTH = 128 * 1024 * 1024 # 128MiB
-
- # Two empty objects used when the geolocalization failed. They have the
- # sames attributes as real countries/cities except that accessing them
- # evaluates to None.
- if geoip2:
- GEOIP_EMPTY_COUNTRY = geoip2.models.Country({})
- GEOIP_EMPTY_CITY = geoip2.models.City({})
-
- # The request mimetypes that transport JSON in their body.
- JSON_MIMETYPES = ('application/json', 'application/json-rpc')
-
- MISSING_CSRF_WARNING = """\
- No CSRF validation token provided for path %r
-
- Odoo URLs are CSRF-protected by default (when accessed with unsafe
- HTTP methods). See
- https://www.odoo.com/documentation/master/developer/reference/addons/http.html#csrf
- for more details.
-
- * if this endpoint is accessed through Odoo via py-QWeb form, embed a CSRF
- token in the form, Tokens are available via `request.csrf_token()`
- can be provided through a hidden input and must be POST-ed named
- `csrf_token` e.g. in your form add:
- <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
-
- * if the form is generated or posted in javascript, the token value is
- available as `csrf_token` on `web.core` and as the `csrf_token`
- value in the default js-qweb execution context
-
- * if the form is accessed by an external third party (e.g. REST API
- endpoint, payment gateway callback) you will need to disable CSRF
- protection (and implement your own protection if necessary) by
- passing the `csrf=False` parameter to the `route` decorator.
- """
-
- # The @route arguments to propagate from the decorated method to the
- # routing rule.
- ROUTING_KEYS = {
- 'defaults', 'subdomain', 'build_only', 'strict_slashes', 'redirect_to',
- 'alias', 'host', 'methods',
- }
-
- if parse_version(werkzeug.__version__) >= parse_version('2.0.2'):
- # Werkzeug 2.0.2 adds the websocket option. If a websocket request
- # (ws/wss) is trying to access an HTTP route, a WebsocketMismatch
- # exception is raised. On the other hand, Werkzeug 0.16 does not
- # support the websocket routing key. In order to bypass this issue,
- # let's add the websocket key only when appropriate.
- ROUTING_KEYS.add('websocket')
-
- # The default duration of a user session cookie. Inactive sessions are reaped
- # server-side as well with a threshold that can be set via an optional
- # config parameter `sessions.max_inactivity_seconds` (default: SESSION_LIFETIME)
- SESSION_LIFETIME = 60 * 60 * 24 * 7
-
- # The cache duration for static content from the filesystem, one week.
- STATIC_CACHE = 60 * 60 * 24 * 7
-
- # The cache duration for content where the url uniquely identifies the
- # content (usually using a hash), one year.
- STATIC_CACHE_LONG = 60 * 60 * 24 * 365
-
-
- # =========================================================
- # Helpers
- # =========================================================
-
- class RegistryError(RuntimeError):
- pass
-
-
- class SessionExpiredException(Exception):
- pass
-
-
- def content_disposition(filename):
- return "attachment; filename*=UTF-8''{}".format(
- url_quote(filename, safe='', unsafe='()<>@,;:"/[]?={}\\*\'%') # RFC6266
- )
-
-
- def db_list(force=False, host=None):
- """
- Get the list of available databases.
-
- :param bool force: See :func:`~odoo.service.db.list_dbs`:
- :param host: The Host used to replace %h and %d in the dbfilters
- regexp. Taken from the current request when omitted.
- :returns: the list of available databases
- :rtype: List[str]
- """
- try:
- dbs = odoo.service.db.list_dbs(force)
- except psycopg2.OperationalError:
- return []
- return db_filter(dbs, host)
-
-
- def db_filter(dbs, host=None):
- """
- Return the subset of ``dbs`` that match the dbfilter or the dbname
- server configuration. In case neither are configured, return ``dbs``
- as-is.
-
- :param Iterable[str] dbs: The list of database names to filter.
- :param host: The Host used to replace %h and %d in the dbfilters
- regexp. Taken from the current request when omitted.
- :returns: The original list filtered.
- :rtype: List[str]
- """
-
- if config['dbfilter']:
- # host
- # -----------
- # www.example.com:80
- # -------
- # domain
- if host is None:
- host = request.httprequest.environ.get('HTTP_HOST', '')
- host = host.partition(':')[0]
- if host.startswith('www.'):
- host = host[4:]
- domain = host.partition('.')[0]
-
- dbfilter_re = re.compile(
- config["dbfilter"].replace("%h", re.escape(host))
- .replace("%d", re.escape(domain)))
- return [db for db in dbs if dbfilter_re.match(db)]
-
- if config['db_name']:
- # In case --db-filter is not provided and --database is passed, Odoo will
- # use the value of --database as a comma separated list of exposed databases.
- exposed_dbs = {db.strip() for db in config['db_name'].split(',')}
- return sorted(exposed_dbs.intersection(dbs))
-
- return list(dbs)
-
-
- def dispatch_rpc(service_name, method, params):
- """
- Perform a RPC call.
-
- :param str service_name: either "common", "db" or "object".
- :param str method: the method name of the given service to execute
- :param Mapping params: the keyword arguments for method call
- :return: the return value of the called method
- :rtype: Any
- """
- rpc_dispatchers = {
- 'common': odoo.service.common.dispatch,
- 'db': odoo.service.db.dispatch,
- 'object': odoo.service.model.dispatch,
- }
-
- with borrow_request():
- threading.current_thread().uid = None
- threading.current_thread().dbname = None
-
- dispatch = rpc_dispatchers[service_name]
- return dispatch(method, params)
-
-
- def get_session_max_inactivity(env):
- if not env or env.cr._closed:
- return SESSION_LIFETIME
-
- ICP = env['ir.config_parameter'].sudo()
-
- try:
- return int(ICP.get_param('sessions.max_inactivity_seconds', SESSION_LIFETIME))
- except ValueError:
- _logger.warning("Invalid value for 'sessions.max_inactivity_seconds', using default value.")
- return SESSION_LIFETIME
-
-
- def is_cors_preflight(request, endpoint):
- return request.httprequest.method == 'OPTIONS' and endpoint.routing.get('cors', False)
-
-
- def serialize_exception(exception):
- name = type(exception).__name__
- module = type(exception).__module__
-
- return {
- 'name': f'{module}.{name}' if module else name,
- 'debug': traceback.format_exc(),
- 'message': exception_to_unicode(exception),
- 'arguments': exception.args,
- 'context': getattr(exception, 'context', {}),
- }
-
-
- # =========================================================
- # File Streaming
- # =========================================================
-
-
- class Stream:
- """
- Send the content of a file, an attachment or a binary field via HTTP
-
- This utility is safe, cache-aware and uses the best available
- streaming strategy. Works best with the --x-sendfile cli option.
-
- Create a Stream via one of the constructors: :meth:`~from_path`:, or
- :meth:`~from_binary_field`:, generate the corresponding HTTP response
- object via :meth:`~get_response`:.
-
- Instantiating a Stream object manually without using one of the
- dedicated constructors is discouraged.
- """
-
- type: str = '' # 'data' or 'path' or 'url'
- data = None
- path = None
- url = None
-
- mimetype = None
- as_attachment = False
- download_name = None
- conditional = True
- etag = True
- last_modified = None
- max_age = None
- immutable = False
- size = None
- public = False
-
- def __init__(self, **kwargs):
- # Remove class methods from the instances
- self.from_path = self.from_attachment = self.from_binary_field = None
- self.__dict__.update(kwargs)
-
- @classmethod
- def from_path(cls, path, filter_ext=('',), public=False):
- """
- Create a :class:`~Stream`: from an addon resource.
-
- :param path: See :func:`~odoo.tools.file_path`
- :param filter_ext: See :func:`~odoo.tools.file_path`
- :param bool public: Advertise the resource as being cachable by
- intermediate proxies, otherwise only let the browser caches
- it.
- """
- path = file_path(path, filter_ext)
- check = adler32(path.encode())
- stat = os.stat(path)
- return cls(
- type='path',
- path=path,
- mimetype=mimetypes.guess_type(path)[0],
- download_name=os.path.basename(path),
- etag=f'{int(stat.st_mtime)}-{stat.st_size}-{check}',
- last_modified=stat.st_mtime,
- size=stat.st_size,
- public=public,
- )
-
- @classmethod
- def from_binary_field(cls, record, field_name):
- """ Create a :class:`~Stream`: from a binary field. """
- data_b64 = record[field_name]
- data = base64.b64decode(data_b64) if data_b64 else b''
- return cls(
- type='data',
- data=data,
- etag=request.env['ir.attachment']._compute_checksum(data),
- last_modified=record.write_date if record._log_access else None,
- size=len(data),
- public=record.env.user._is_public() # good enough
- )
-
- def read(self):
- """ Get the stream content as bytes. """
- if self.type == 'url':
- raise ValueError("Cannot read an URL")
-
- if self.type == 'data':
- return self.data
-
- with open(self.path, 'rb') as file:
- return file.read()
-
- def get_response(
- self,
- as_attachment=None,
- immutable=None,
- content_security_policy="default-src 'none'",
- **send_file_kwargs
- ):
- """
- Create the corresponding :class:`~Response` for the current stream.
-
- :param bool|None as_attachment: Indicate to the browser that it
- should offer to save the file instead of displaying it.
- :param bool|None immutable: Add the ``immutable`` directive to
- the ``Cache-Control`` response header, allowing intermediary
- proxies to aggressively cache the response. This option also
- set the ``max-age`` directive to 1 year.
- :param str|None content_security_policy: Optional value for the
- ``Content-Security-Policy`` (CSP) header. This header is
- used by browsers to allow/restrict the downloaded resource
- to itself perform new http requests. By default CSP is set
- to ``"default-scr 'none'"`` which restrict all requests.
- :param send_file_kwargs: Other keyword arguments to send to
- :func:`odoo.tools._vendor.send_file.send_file` instead of
- the stream sensitive values. Discouraged.
- """
- assert self.type in ('url', 'data', 'path'), "Invalid type: {self.type!r}, should be 'url', 'data' or 'path'."
- assert getattr(self, self.type) is not None, "There is nothing to stream, missing {self.type!r} attribute."
-
- if self.type == 'url':
- if self.max_age is not None:
- res = request.redirect(self.url, code=302, local=False)
- res.headers['Cache-Control'] = f'max-age={self.max_age}'
- return res
- return request.redirect(self.url, code=301, local=False)
-
- if as_attachment is None:
- as_attachment = self.as_attachment
- if immutable is None:
- immutable = self.immutable
-
- send_file_kwargs = {
- 'mimetype': self.mimetype,
- 'as_attachment': as_attachment,
- 'download_name': self.download_name,
- 'conditional': self.conditional,
- 'etag': self.etag,
- 'last_modified': self.last_modified,
- 'max_age': STATIC_CACHE_LONG if immutable else self.max_age,
- 'environ': request.httprequest.environ,
- 'response_class': Response,
- **send_file_kwargs,
- }
-
- if self.type == 'data':
- res = _send_file(BytesIO(self.data), **send_file_kwargs)
- else: # self.type == 'path'
- send_file_kwargs['use_x_sendfile'] = False
- if config['x_sendfile']:
- with contextlib.suppress(ValueError): # outside of the filestore
- fspath = Path(self.path).relative_to(opj(config['data_dir'], 'filestore'))
- x_accel_redirect = f'/web/filestore/{fspath}'
- send_file_kwargs['use_x_sendfile'] = True
-
- res = _send_file(self.path, **send_file_kwargs)
- if 'X-Sendfile' in res.headers:
- res.headers['X-Accel-Redirect'] = x_accel_redirect
-
- # In case of X-Sendfile/X-Accel-Redirect, the body is empty,
- # yet werkzeug gives the length of the file. This makes
- # NGINX wait for content that'll never arrive.
- res.headers['Content-Length'] = '0'
-
- res.headers['X-Content-Type-Options'] = 'nosniff'
-
- if content_security_policy: # see also Application.set_csp()
- res.headers['Content-Security-Policy'] = content_security_policy
-
- if self.public:
- if (res.cache_control.max_age or 0) > 0:
- res.cache_control.public = True
- else:
- res.cache_control.pop('public', '')
- res.cache_control.private = True
- if immutable:
- res.cache_control['immutable'] = None # None sets the directive
-
- return res
-
-
- # =========================================================
- # Controller and routes
- # =========================================================
-
- class Controller:
- """
- Class mixin that provide module controllers the ability to serve
- content over http and to be extended in child modules.
-
- Each class :ref:`inheriting <python:tut-inheritance>` from
- :class:`~odoo.http.Controller` can use the :func:`~odoo.http.route`:
- decorator to route matching incoming web requests to decorated
- methods.
-
- Like models, controllers can be extended by other modules. The
- extension mechanism is different because controllers can work in a
- database-free environment and therefore cannot use
- :class:~odoo.api.Registry:.
-
- To *override* a controller, :ref:`inherit <python:tut-inheritance>`
- from its class, override relevant methods and re-expose them with
- :func:`~odoo.http.route`:. Please note that the decorators of all
- methods are combined, if the overriding method’s decorator has no
- argument all previous ones will be kept, any provided argument will
- override previously defined ones.
-
- .. code-block:
-
- class GreetingController(odoo.http.Controller):
- @route('/greet', type='http', auth='public')
- def greeting(self):
- return 'Hello'
-
- class UserGreetingController(GreetingController):
- @route(auth='user') # override auth, keep path and type
- def greeting(self):
- return super().handler()
- """
- children_classes = collections.defaultdict(list) # indexed by module
-
- @classmethod
- def __init_subclass__(cls):
- super().__init_subclass__()
- if Controller in cls.__bases__:
- path = cls.__module__.split('.')
- module = path[2] if path[:2] == ['odoo', 'addons'] else ''
- Controller.children_classes[module].append(cls)
-
-
- def route(route=None, **routing):
- """
- Decorate a controller method in order to route incoming requests
- matching the given URL and options to the decorated method.
-
- .. warning::
- It is mandatory to re-decorate any method that is overridden in
- controller extensions but the arguments can be omitted. See
- :class:`~odoo.http.Controller` for more details.
-
- :param Union[str, Iterable[str]] route: The paths that the decorated
- method is serving. Incoming HTTP request paths matching this
- route will be routed to this decorated method. See `werkzeug
- routing documentation <http://werkzeug.pocoo.org/docs/routing/>`_
- for the format of route expressions.
- :param str type: The type of request, either ``'json'`` or
- ``'http'``. It describes where to find the request parameters
- and how to serialize the response.
- :param str auth: The authentication method, one of the following:
-
- * ``'user'``: The user must be authenticated and the current
- request will be executed using the rights of the user.
- * ``'bearer'``: The user is authenticated using an "Authorization"
- request header, using the Bearer scheme with an API token.
- The request will be executed with the permissions of the
- corresponding user. If the header is missing, the request
- must belong to an authentication session, as for the "user"
- authentication method.
- * ``'public'``: The user may or may not be authenticated. If he
- isn't, the current request will be executed using the shared
- Public user.
- * ``'none'``: The method is always active, even if there is no
- database. Mainly used by the framework and authentication
- modules. The request code will not have any facilities to
- access the current user.
- :param Iterable[str] methods: A list of http methods (verbs) this
- route applies to. If not specified, all methods are allowed.
- :param str cors: The Access-Control-Allow-Origin cors directive value.
- :param bool csrf: Whether CSRF protection should be enabled for the
- route. Enabled by default for ``'http'``-type requests, disabled
- by default for ``'json'``-type requests.
- :param Union[bool, Callable[[registry, request], bool]] readonly:
- Whether this endpoint should open a cursor on a read-only
- replica instead of (by default) the primary read/write database.
- :param Callable[[Exception], Response] handle_params_access_error:
- Implement a custom behavior if an error occurred when retrieving the record
- from the URL parameters (access error or missing error).
- """
- def decorator(endpoint):
- fname = f"<function {endpoint.__module__}.{endpoint.__name__}>"
-
- # Sanitize the routing
- assert routing.get('type', 'http') in _dispatchers.keys()
- if route:
- routing['routes'] = [route] if isinstance(route, str) else route
- wrong = routing.pop('method', None)
- if wrong is not None:
- _logger.warning("%s defined with invalid routing parameter 'method', assuming 'methods'", fname)
- routing['methods'] = wrong
-
- @functools.wraps(endpoint)
- def route_wrapper(self, *args, **params):
- params_ok = filter_kwargs(endpoint, params)
- params_ko = set(params) - set(params_ok)
- if params_ko:
- _logger.warning("%s called ignoring args %s", fname, params_ko)
-
- result = endpoint(self, *args, **params_ok)
- if routing['type'] == 'http': # _generate_routing_rules() ensures type is set
- return Response.load(result)
- return result
-
- route_wrapper.original_routing = routing
- route_wrapper.original_endpoint = endpoint
- return route_wrapper
- return decorator
-
-
- def _generate_routing_rules(modules, nodb_only, converters=None):
- """
- Two-fold algorithm used to (1) determine which method in the
- controller inheritance tree should bind to what URL with respect to
- the list of installed modules and (2) merge the various @route
- arguments of said method with the @route arguments of the method it
- overrides.
- """
- def is_valid(cls):
- """ Determine if the class is defined in an addon. """
- path = cls.__module__.split('.')
- return path[:2] == ['odoo', 'addons'] and path[2] in modules
-
- def get_leaf_classes(cls):
- """
- Find the classes that have no child and that have ``cls`` as
- ancestor.
- """
- result = []
- for subcls in cls.__subclasses__():
- if is_valid(subcls):
- result.extend(get_leaf_classes(subcls))
- if not result and is_valid(cls):
- result.append(cls)
- return result
-
- def build_controllers():
- """
- Create dummy controllers that inherit only from the controllers
- defined at the given ``modules`` (often system wide modules or
- installed modules). Modules in this context are Odoo addons.
- """
- # Controllers defined outside of odoo addons are outside of the
- # controller inheritance/extension mechanism.
- yield from (ctrl() for ctrl in Controller.children_classes.get('', []))
-
- # Controllers defined inside of odoo addons can be extended in
- # other installed addons. Rebuild the class inheritance here.
- highest_controllers = []
- for module in modules:
- highest_controllers.extend(Controller.children_classes.get(module, []))
-
- for top_ctrl in highest_controllers:
- leaf_controllers = list(unique(get_leaf_classes(top_ctrl)))
-
- name = top_ctrl.__name__
- if leaf_controllers != [top_ctrl]:
- name += ' (extended by %s)' % ', '.join(
- bot_ctrl.__name__
- for bot_ctrl in leaf_controllers
- if bot_ctrl is not top_ctrl
- )
-
- Ctrl = type(name, tuple(reversed(leaf_controllers)), {})
- yield Ctrl()
-
- for ctrl in build_controllers():
- for method_name, method in inspect.getmembers(ctrl, inspect.ismethod):
-
- # Skip this method if it is not @route decorated anywhere in
- # the hierarchy
- def is_method_a_route(cls):
- return getattr(getattr(cls, method_name, None), 'original_routing', None) is not None
- if not any(map(is_method_a_route, type(ctrl).mro())):
- continue
-
- merged_routing = {
- # 'type': 'http', # set below
- 'auth': 'user',
- 'methods': None,
- 'routes': [],
- }
-
- for cls in unique(reversed(type(ctrl).mro()[:-2])): # ancestors first
- if method_name not in cls.__dict__:
- continue
- submethod = getattr(cls, method_name)
-
- if not hasattr(submethod, 'original_routing'):
- _logger.warning("The endpoint %s is not decorated by @route(), decorating it myself.", f'{cls.__module__}.{cls.__name__}.{method_name}')
- submethod = route()(submethod)
-
- _check_and_complete_route_definition(cls, submethod, merged_routing)
-
- merged_routing.update(submethod.original_routing)
-
- if not merged_routing['routes']:
- _logger.warning("%s is a controller endpoint without any route, skipping.", f'{cls.__module__}.{cls.__name__}.{method_name}')
- continue
-
- if nodb_only and merged_routing['auth'] != "none":
- continue
-
- for url in merged_routing['routes']:
- # duplicates the function (partial) with a copy of the
- # original __dict__ (update_wrapper) to keep a reference
- # to `original_routing` and `original_endpoint`, assign
- # the merged routing ONLY on the duplicated function to
- # ensure method's immutability.
- endpoint = functools.partial(method)
- functools.update_wrapper(endpoint, method)
- endpoint.routing = merged_routing
-
- yield (url, endpoint)
-
-
- def _check_and_complete_route_definition(controller_cls, submethod, merged_routing):
- """Verify and complete the route definition.
-
- * Ensure 'type' is defined on each method's own routing.
- * Ensure overrides don't change the routing type or the read/write mode
-
- :param submethod: route method
- :param dict merged_routing: accumulated routing values
- """
- default_type = submethod.original_routing.get('type', 'http')
- routing_type = merged_routing.setdefault('type', default_type)
- if submethod.original_routing.get('type') not in (None, routing_type):
- _logger.warning(
- "The endpoint %s changes the route type, using the original type: %r.",
- f'{controller_cls.__module__}.{controller_cls.__name__}.{submethod.__name__}',
- routing_type)
- submethod.original_routing['type'] = routing_type
-
- default_auth = submethod.original_routing.get('auth', merged_routing['auth'])
- default_mode = submethod.original_routing.get('readonly', default_auth == 'none')
- parent_readonly = merged_routing.setdefault('readonly', default_mode)
- child_readonly = submethod.original_routing.get('readonly')
- if child_readonly not in (None, parent_readonly) and not callable(child_readonly):
- _logger.warning(
- "The endpoint %s made the route %s altough its parent was defined as %s. Setting the route read/write.",
- f'{controller_cls.__module__}.{controller_cls.__name__}.{submethod.__name__}',
- 'readonly' if child_readonly else 'read/write',
- 'readonly' if parent_readonly else 'read/write',
- )
- submethod.original_routing['readonly'] = False
-
-
- # =========================================================
- # Session
- # =========================================================
-
- _base64_urlsafe_re = re.compile(r'^[A-Za-z0-9_-]{84}$')
-
-
- class FilesystemSessionStore(sessions.FilesystemSessionStore):
- """ Place where to load and save session objects. """
- def get_session_filename(self, sid):
- # scatter sessions across 4096 (64^2) directories
- if not self.is_valid_key(sid):
- raise ValueError(f'Invalid session id {sid!r}')
- sha_dir = sid[:2]
- dirname = os.path.join(self.path, sha_dir)
- session_path = os.path.join(dirname, sid)
- return session_path
-
- def save(self, session):
- session_path = self.get_session_filename(session.sid)
- dirname = os.path.dirname(session_path)
- if not os.path.isdir(dirname):
- with contextlib.suppress(OSError):
- os.mkdir(dirname, 0o0755)
- super().save(session)
-
- def get(self, sid):
- # retro compatibility
- old_path = super().get_session_filename(sid)
- session_path = self.get_session_filename(sid)
- if os.path.isfile(old_path) and not os.path.isfile(session_path):
- dirname = os.path.dirname(session_path)
- if not os.path.isdir(dirname):
- with contextlib.suppress(OSError):
- os.mkdir(dirname, 0o0755)
- with contextlib.suppress(OSError):
- os.rename(old_path, session_path)
- return super().get(sid)
-
- def rotate(self, session, env):
- self.delete(session)
- session.sid = self.generate_key()
- if session.uid and env:
- session.session_token = security.compute_session_token(session, env)
- session.should_rotate = False
- self.save(session)
-
- def vacuum(self, max_lifetime=SESSION_LIFETIME):
- threshold = time.time() - max_lifetime
- for fname in glob.iglob(os.path.join(root.session_store.path, '*', '*')):
- path = os.path.join(root.session_store.path, fname)
- with contextlib.suppress(OSError):
- if os.path.getmtime(path) < threshold:
- os.unlink(path)
-
- def generate_key(self, salt=None):
- # The generated key is case sensitive (base64) and the length is 84 chars.
- # In the worst-case scenario, i.e. in an insensitive filesystem (NTFS for example)
- # taking into account the proportion of characters in the pool and a length
- # of 42 (stored part in the database), the entropy for the base64 generated key
- # is 217.875 bits which is better than the 160 bits entropy of a hexadecimal key
- # with a length of 40 (method ``generate_key`` of ``SessionStore``).
- # The risk of collision is negligible in practice.
- # Formulas:
- # - L: length of generated word
- # - p_char: probability of obtaining the character in the pool
- # - n: size of the pool
- # - k: number of generated word
- # Entropy = - L * sum(p_char * log2(p_char))
- # Collision ~= (1 - exp((-k * (k - 1)) / (2 * (n**L))))
- key = str(time.time()).encode() + os.urandom(64)
- hash_key = sha512(key).digest()[:-1] # prevent base64 padding
- return base64.urlsafe_b64encode(hash_key).decode('utf-8')
-
- def is_valid_key(self, key):
- return _base64_urlsafe_re.match(key) is not None
-
- def delete_from_identifiers(self, identifiers):
- files_to_unlink = []
- for identifier in identifiers:
- # Avoid to remove a session if less than 42 chars.
- # This prevent malicious user to delete sessions from a different
- # database by specifying a ``res.device.log`` with only 2 characters.
- if len(identifier) < 42:
- continue
- normalized_path = os.path.normpath(os.path.join(self.path, identifier[:2], identifier + '*'))
- if normalized_path.startswith(self.path):
- files_to_unlink.extend(glob.glob(normalized_path))
- for fn in files_to_unlink:
- with contextlib.suppress(OSError):
- os.unlink(fn)
-
-
- class Session(collections.abc.MutableMapping):
- """ Structure containing data persisted across requests. """
- __slots__ = ('can_save', '_Session__data', 'is_dirty', 'is_new',
- 'should_rotate', 'sid')
-
- def __init__(self, data, sid, new=False):
- self.can_save = True
- self.__data = {}
- self.update(data)
- self.is_dirty = False
- self.is_new = new
- self.should_rotate = False
- self.sid = sid
-
- #
- # MutableMapping implementation with DocDict-like extension
- #
- def __getitem__(self, item):
- return self.__data[item]
-
- def __setitem__(self, item, value):
- value = json.loads(json.dumps(value))
- if item not in self.__data or self.__data[item] != value:
- self.is_dirty = True
- self.__data[item] = value
-
- def __delitem__(self, item):
- del self.__data[item]
- self.is_dirty = True
-
- def __len__(self):
- return len(self.__data)
-
- def __iter__(self):
- return iter(self.__data)
-
- def __getattr__(self, attr):
- return self.get(attr, None)
-
- def __setattr__(self, key, val):
- if key in self.__slots__:
- super().__setattr__(key, val)
- else:
- self[key] = val
-
- def clear(self):
- self.__data.clear()
- self.is_dirty = True
-
- #
- # Session methods
- #
- def authenticate(self, dbname, credential):
- """
- Authenticate the current user with the given db, login and
- credential. If successful, store the authentication parameters in
- the current session, unless multi-factor-auth (MFA) is
- activated. In that case, that last part will be done by
- :ref:`finalize`.
-
- .. versionchanged:: saas-15.3
- The current request is no longer updated using the user and
- context of the session when the authentication is done using
- a database different than request.db. It is up to the caller
- to open a new cursor/registry/env on the given database.
- """
- wsgienv = {
- 'interactive': True,
- 'base_location': request.httprequest.url_root.rstrip('/'),
- 'HTTP_HOST': request.httprequest.environ['HTTP_HOST'],
- 'REMOTE_ADDR': request.httprequest.environ['REMOTE_ADDR'],
- }
-
- registry = Registry(dbname)
- auth_info = registry['res.users'].authenticate(dbname, credential, wsgienv)
- pre_uid = auth_info['uid']
-
- self.uid = None
- self.pre_login = credential['login']
- self.pre_uid = pre_uid
-
- with registry.cursor() as cr:
- env = odoo.api.Environment(cr, pre_uid, {})
-
- # if 2FA is disabled we finalize immediately
- user = env['res.users'].browse(pre_uid)
- if auth_info.get('mfa') == 'skip' or not user._mfa_url():
- self.finalize(env)
-
- if request and request.session is self and request.db == dbname:
- request.env = odoo.api.Environment(request.env.cr, self.uid, self.context)
- request.update_context(lang=get_lang(request.env(user=pre_uid)).code)
- # request env needs to be able to access the latest changes from the auth layers
- request.env.cr.commit()
-
- return auth_info
-
- def finalize(self, env):
- """
- Finalizes a partial session, should be called on MFA validation
- to convert a partial / pre-session into a logged-in one.
- """
- login = self.pop('pre_login')
- uid = self.pop('pre_uid')
-
- env = env(user=uid)
- user_context = dict(env['res.users'].context_get())
-
- self.should_rotate = True
- self.update({
- 'db': env.registry.db_name,
- 'login': login,
- 'uid': uid,
- 'context': user_context,
- 'session_token': env.user._compute_session_token(self.sid),
- })
-
- def logout(self, keep_db=False):
- db = self.db if keep_db else get_default_session()['db'] # None
- debug = self.debug
- self.clear()
- self.update(get_default_session(), db=db, debug=debug)
- self.context['lang'] = request.default_lang() if request else DEFAULT_LANG
- self.should_rotate = True
-
- if request and request.env:
- request.env['ir.http']._post_logout()
-
- def touch(self):
- self.is_dirty = True
-
- def update_trace(self, request):
- """
- :return: dict if a device log has to be inserted, ``None`` otherwise
- """
- if self._trace_disable:
- # To avoid generating useless logs, e.g. for automated technical sessions,
- # a session can be flagged with `_trace_disable`. This should never be done
- # without a proper assessment of the consequences for auditability.
- # Non-admin users have no direct or indirect way to set this flag, so it can't
- # be abused by unprivileged users. Such sessions will of course still be
- # subject to all other auditing mechanisms (server logs, web proxy logs,
- # metadata tracking on modified records, etc.)
- return
-
- user_agent = request.httprequest.user_agent
- platform = user_agent.platform
- browser = user_agent.browser
- ip_address = request.httprequest.remote_addr
- now = int(datetime.now().timestamp())
- for trace in self._trace:
- if trace['platform'] == platform and trace['browser'] == browser and trace['ip_address'] == ip_address:
- # If the device logs are not up to date (i.e. not updated for one hour or more)
- if bool(now - trace['last_activity'] >= 3600):
- trace['last_activity'] = now
- self.is_dirty = True
- return trace
- return
- new_trace = {
- 'platform': platform,
- 'browser': browser,
- 'ip_address': ip_address,
- 'first_activity': now,
- 'last_activity': now
- }
- self._trace.append(new_trace)
- self.is_dirty = True
- return new_trace
-
-
- # =========================================================
- # GeoIP
- # =========================================================
-
- class GeoIP(collections.abc.Mapping):
- """
- Ip Geolocalization utility, determine information such as the
- country or the timezone of the user based on their IP Address.
-
- The instances share the same API as `:class:`geoip2.models.City`
- <https://geoip2.readthedocs.io/en/latest/#geoip2.models.City>`_.
-
- When the IP couldn't be geolocalized (missing database, bad address)
- then an empty object is returned. This empty object can be used like
- a regular one with the exception that all info are set None.
-
- :param str ip: The IP Address to geo-localize
-
- .. note:
-
- The geoip info the the current request are available at
- :attr:`~odoo.http.request.geoip`.
-
- .. code-block:
-
- >>> GeoIP('127.0.0.1').country.iso_code
- >>> odoo_ip = socket.gethostbyname('odoo.com')
- >>> GeoIP(odoo_ip).country.iso_code
- 'FR'
- """
-
- def __init__(self, ip):
- self.ip = ip
-
- @lazy_property
- def _city_record(self):
- try:
- return root.geoip_city_db.city(self.ip)
- except (OSError, maxminddb.InvalidDatabaseError):
- return GEOIP_EMPTY_CITY
- except geoip2.errors.AddressNotFoundError:
- return GEOIP_EMPTY_CITY
-
- @lazy_property
- def _country_record(self):
- if '_city_record' in vars(self):
- # the City class inherits from the Country class and the
- # city record is in cache already, save a geolocalization
- return self._city_record
- try:
- return root.geoip_country_db.country(self.ip)
- except (OSError, maxminddb.InvalidDatabaseError):
- return self._city_record
- except geoip2.errors.AddressNotFoundError:
- return GEOIP_EMPTY_COUNTRY
-
- @property
- def country_name(self):
- return self.country.name or self.continent.name
-
- @property
- def country_code(self):
- return self.country.iso_code or self.continent.code
-
- def __getattr__(self, attr):
- # Be smart and determine whether the attribute exists on the
- # country object or on the city object.
- if hasattr(GEOIP_EMPTY_COUNTRY, attr):
- return getattr(self._country_record, attr)
- if hasattr(GEOIP_EMPTY_CITY, attr):
- return getattr(self._city_record, attr)
- raise AttributeError(f"{self} has no attribute {attr!r}")
-
- def __bool__(self):
- return self.country_name is not None
-
- # Old dict API, undocumented for now, will be deprecated some day
- def __getitem__(self, item):
- if item == 'country_name':
- return self.country_name
-
- if item == 'country_code':
- return self.country_code
-
- if item == 'city':
- return self.city.name
-
- if item == 'latitude':
- return self.location.latitude
-
- if item == 'longitude':
- return self.location.longitude
-
- if item == 'region':
- return self.subdivisions[0].iso_code if self.subdivisions else None
-
- if item == 'time_zone':
- return self.location.time_zone
-
- raise KeyError(item)
-
- def __iter__(self):
- raise NotImplementedError("The dictionnary GeoIP API is deprecated.")
-
- def __len__(self):
- raise NotImplementedError("The dictionnary GeoIP API is deprecated.")
-
-
- # =========================================================
- # Request and Response
- # =========================================================
-
- # Thread local global request object
- _request_stack = werkzeug.local.LocalStack()
- request = _request_stack()
-
- @contextlib.contextmanager
- def borrow_request():
- """ Get the current request and unexpose it from the local stack. """
- req = _request_stack.pop()
- try:
- yield req
- finally:
- _request_stack.push(req)
-
-
- def make_request_wrap_methods(attr):
- def getter(self):
- return getattr(self._HTTPRequest__wrapped, attr)
-
- def setter(self, value):
- return setattr(self._HTTPRequest__wrapped, attr, value)
-
- return getter, setter
-
-
- class HTTPRequest:
- def __init__(self, environ):
- httprequest = werkzeug.wrappers.Request(environ)
- httprequest.user_agent_class = UserAgent # use vendored userAgent since it will be removed in 2.1
- httprequest.parameter_storage_class = werkzeug.datastructures.ImmutableOrderedMultiDict
- httprequest.max_content_length = DEFAULT_MAX_CONTENT_LENGTH
- httprequest.max_form_memory_size = 10 * 1024 * 1024 # 10 MB
-
- self.__wrapped = httprequest
- self.__environ = self.__wrapped.environ
- self.environ = self.headers.environ = {
- key: value
- for key, value in self.__environ.items()
- if (not key.startswith(('werkzeug.', 'wsgi.', 'socket')) or key in ['wsgi.url_scheme', 'werkzeug.proxy_fix.orig'])
- }
-
- def __enter__(self):
- return self
-
-
- HTTPREQUEST_ATTRIBUTES = [
- '__str__', '__repr__', '__exit__',
- 'accept_charsets', 'accept_languages', 'accept_mimetypes', 'access_route', 'args', 'authorization', 'base_url',
- 'charset', 'content_encoding', 'content_length', 'content_md5', 'content_type', 'cookies', 'data', 'date',
- 'encoding_errors', 'files', 'form', 'full_path', 'get_data', 'get_json', 'headers', 'host', 'host_url', 'if_match',
- 'if_modified_since', 'if_none_match', 'if_range', 'if_unmodified_since', 'is_json', 'is_secure', 'json',
- 'max_content_length', 'method', 'mimetype', 'mimetype_params', 'origin', 'path', 'pragma', 'query_string', 'range',
- 'referrer', 'remote_addr', 'remote_user', 'root_path', 'root_url', 'scheme', 'script_root', 'server', 'session',
- 'trusted_hosts', 'url', 'url_charset', 'url_root', 'user_agent', 'values',
- ]
- for attr in HTTPREQUEST_ATTRIBUTES:
- setattr(HTTPRequest, attr, property(*make_request_wrap_methods(attr)))
-
-
- class Response(werkzeug.wrappers.Response):
- """
- Outgoing HTTP response with body, status, headers and qweb support.
- In addition to the :class:`werkzeug.wrappers.Response` parameters,
- this class's constructor can take the following additional
- parameters for QWeb Lazy Rendering.
-
- :param str template: template to render
- :param dict qcontext: Rendering context to use
- :param int uid: User id to use for the ir.ui.view render call,
- ``None`` to use the request's user (the default)
-
- these attributes are available as parameters on the Response object
- and can be altered at any time before rendering
-
- Also exposes all the attributes and methods of
- :class:`werkzeug.wrappers.Response`.
- """
- default_mimetype = 'text/html'
-
- def __init__(self, *args, **kw):
- template = kw.pop('template', None)
- qcontext = kw.pop('qcontext', None)
- uid = kw.pop('uid', None)
- super().__init__(*args, **kw)
- self.set_default(template, qcontext, uid)
-
- @classmethod
- def load(cls, result, fname="<function>"):
- """
- Convert the return value of an endpoint into a Response.
-
- :param result: The endpoint return value to load the Response from.
- :type result: Union[Response, werkzeug.wrappers.BaseResponse,
- werkzeug.exceptions.HTTPException, str, bytes, NoneType]
- :param str fname: The endpoint function name wherefrom the
- result emanated, used for logging.
- :returns: The created :class:`~odoo.http.Response`.
- :rtype: Response
- :raises TypeError: When ``result`` type is none of the above-
- mentioned type.
- """
- if isinstance(result, Response):
- return result
-
- if isinstance(result, werkzeug.exceptions.HTTPException):
- _logger.warning("%s returns an HTTPException instead of raising it.", fname)
- raise result
-
- if isinstance(result, werkzeug.wrappers.Response):
- response = cls.force_type(result)
- response.set_default()
- return response
-
- if isinstance(result, (bytes, str, type(None))):
- return cls(result)
-
- raise TypeError(f"{fname} returns an invalid value: {result}")
-
- def set_default(self, template=None, qcontext=None, uid=None):
- self.template = template
- self.qcontext = qcontext or dict()
- self.qcontext['response_template'] = self.template
- self.uid = uid
-
- @property
- def is_qweb(self):
- return self.template is not None
-
- def render(self):
- """ Renders the Response's template, returns the result. """
- self.qcontext['request'] = request
- return request.env["ir.ui.view"]._render_template(self.template, self.qcontext)
-
- def flatten(self):
- """
- Forces the rendering of the response's template, sets the result
- as response body and unsets :attr:`.template`
- """
- if self.template:
- self.response.append(self.render())
- self.template = None
-
- def set_cookie(self, key, value='', max_age=None, expires=-1, path='/', domain=None, secure=False, httponly=False, samesite=None, cookie_type='required'):
- """
- The default expires in Werkzeug is None, which means a session cookie.
- We want to continue to support the session cookie, but not by default.
- Now the default is arbitrary 1 year.
- So if you want a cookie of session, you have to explicitly pass expires=None.
- """
- if expires == -1: # not provided value -> default value -> 1 year
- expires = datetime.now() + timedelta(days=365)
-
- if request.db and not request.env['ir.http']._is_allowed_cookie(cookie_type):
- max_age = 0
- super().set_cookie(key, value=value, max_age=max_age, expires=expires, path=path, domain=domain, secure=secure, httponly=httponly, samesite=samesite)
-
-
- class FutureResponse:
- """
- werkzeug.Response mock class that only serves as placeholder for
- headers to be injected in the final response.
- """
- # used by werkzeug.Response.set_cookie
- charset = 'utf-8'
- max_cookie_size = 4093
-
- def __init__(self):
- self.headers = werkzeug.datastructures.Headers()
-
- @property
- def _charset(self):
- return self.charset
-
- @functools.wraps(werkzeug.Response.set_cookie)
- def set_cookie(self, key, value='', max_age=None, expires=-1, path='/', domain=None, secure=False, httponly=False, samesite=None, cookie_type='required'):
- if expires == -1: # not forced value -> default value -> 1 year
- expires = datetime.now() + timedelta(days=365)
-
- if request.db and not request.env['ir.http']._is_allowed_cookie(cookie_type):
- max_age = 0
- werkzeug.Response.set_cookie(self, key, value=value, max_age=max_age, expires=expires, path=path, domain=domain, secure=secure, httponly=httponly, samesite=samesite)
-
-
- class Request:
- """
- Wrapper around the incoming HTTP request with deserialized request
- parameters, session utilities and request dispatching logic.
- """
-
- def __init__(self, httprequest):
- self.httprequest = httprequest
- self.future_response = FutureResponse()
- self.dispatcher = _dispatchers['http'](self) # until we match
- #self.params = {} # set by the Dispatcher
-
- self.geoip = GeoIP(httprequest.remote_addr)
- self.registry = None
- self.env = None
-
- def _post_init(self):
- self.session, self.db = self._get_session_and_dbname()
- self._post_init = None
-
- def _get_session_and_dbname(self):
- sid = self.httprequest.cookies.get('session_id')
- if not sid or not root.session_store.is_valid_key(sid):
- session = root.session_store.new()
- else:
- session = root.session_store.get(sid)
- session.sid = sid # in case the session was not persisted
-
- for key, val in get_default_session().items():
- session.setdefault(key, val)
- if not session.context.get('lang'):
- session.context['lang'] = self.default_lang()
-
- dbname = None
- host = self.httprequest.environ['HTTP_HOST']
- if session.db and db_filter([session.db], host=host):
- dbname = session.db
- else:
- all_dbs = db_list(force=True, host=host)
- if len(all_dbs) == 1:
- dbname = all_dbs[0] # monodb
-
- if session.db != dbname:
- if session.db:
- _logger.warning("Logged into database %r, but dbfilter rejects it; logging session out.", session.db)
- session.logout(keep_db=False)
- session.db = dbname
-
- session.is_dirty = False
- return session, dbname
-
- def _open_registry(self):
- try:
- registry = Registry(self.db)
- # use a RW cursor! Sequence data is not replicated and would
- # be invalid if accessed on a readonly replica. Cfr task-4399456
- cr_readwrite = registry.cursor(readonly=False)
- registry = registry.check_signaling(cr_readwrite)
- except (AttributeError, psycopg2.OperationalError, psycopg2.ProgrammingError) as e:
- raise RegistryError(f"Cannot get registry {self.db}") from e
- return registry, cr_readwrite
-
- # =====================================================
- # Getters and setters
- # =====================================================
- def update_env(self, user=None, context=None, su=None):
- """ Update the environment of the current request.
-
- :param user: optional user/user id to change the current user
- :type user: int or :class:`res.users record<~odoo.addons.base.models.res_users.Users>`
- :param dict context: optional context dictionary to change the current context
- :param bool su: optional boolean to change the superuser mode
- """
- cr = None # None is a sentinel, it keeps the same cursor
- self.env = self.env(cr, user, context, su)
- threading.current_thread().uid = self.env.uid
-
- def update_context(self, **overrides):
- """
- Override the environment context of the current request with the
- values of ``overrides``. To replace the entire context, please
- use :meth:`~update_env` instead.
- """
- self.update_env(context=dict(self.env.context, **overrides))
-
- @property
- def context(self):
- return self.env.context
-
- @context.setter
- def context(self, value):
- raise NotImplementedError("Use request.update_context instead.")
-
- @property
- def uid(self):
- return self.env.uid
-
- @uid.setter
- def uid(self, value):
- raise NotImplementedError("Use request.update_env instead.")
-
- @property
- def cr(self):
- return self.env.cr
-
- @cr.setter
- def cr(self, value):
- if value is None:
- raise NotImplementedError("Close the cursor instead.")
- raise ValueError("You cannot replace the cursor attached to the current request.")
-
- _cr = cr
-
- @lazy_property
- def best_lang(self):
- lang = self.httprequest.accept_languages.best
- if not lang:
- return None
-
- try:
- code, territory, _, _ = babel.core.parse_locale(lang, sep='-')
- if territory:
- lang = f'{code}_{territory}'
- else:
- lang = babel.core.LOCALE_ALIASES[code]
- return lang
- except (ValueError, KeyError):
- return None
-
- @lazy_property
- def cookies(self):
- cookies = werkzeug.datastructures.MultiDict(self.httprequest.cookies)
- if self.registry:
- self.registry['ir.http']._sanitize_cookies(cookies)
- return werkzeug.datastructures.ImmutableMultiDict(cookies)
-
- # =====================================================
- # Helpers
- # =====================================================
- def csrf_token(self, time_limit=None):
- """
- Generates and returns a CSRF token for the current session
-
- :param Optional[int] time_limit: the CSRF token should only be
- valid for the specified duration (in second), by default
- 48h, ``None`` for the token to be valid as long as the
- current user's session is.
- :returns: ASCII token string
- :rtype: str
- """
- secret = self.env['ir.config_parameter'].sudo().get_param('database.secret')
- if not secret:
- raise ValueError("CSRF protection requires a configured database secret")
-
- # if no `time_limit` => distant 1y expiry so max_ts acts as salt, e.g. vs BREACH
- max_ts = int(time.time() + (time_limit or CSRF_TOKEN_SALT))
- msg = f'{self.session.sid}{max_ts}'.encode('utf-8')
-
- hm = hmac.new(secret.encode('ascii'), msg, hashlib.sha1).hexdigest()
- return f'{hm}o{max_ts}'
-
- def validate_csrf(self, csrf):
- """
- Is the given csrf token valid ?
-
- :param str csrf: The token to validate.
- :returns: ``True`` when valid, ``False`` when not.
- :rtype: bool
- """
- if not csrf:
- return False
-
- secret = self.env['ir.config_parameter'].sudo().get_param('database.secret')
- if not secret:
- raise ValueError("CSRF protection requires a configured database secret")
-
- hm, _, max_ts = csrf.rpartition('o')
- msg = f'{self.session.sid}{max_ts}'.encode('utf-8')
-
- if max_ts:
- try:
- if int(max_ts) < int(time.time()):
- return False
- except ValueError:
- return False
-
- hm_expected = hmac.new(secret.encode('ascii'), msg, hashlib.sha1).hexdigest()
- return consteq(hm, hm_expected)
-
- def default_context(self):
- return dict(get_default_session()['context'], lang=self.default_lang())
-
- def default_lang(self):
- """Returns default user language according to request specification
-
- :returns: Preferred language if specified or 'en_US'
- :rtype: str
- """
- return self.best_lang or DEFAULT_LANG
-
- def get_http_params(self):
- """
- Extract key=value pairs from the query string and the forms
- present in the body (both application/x-www-form-urlencoded and
- multipart/form-data).
-
- :returns: The merged key-value pairs.
- :rtype: dict
- """
- params = {
- **self.httprequest.args,
- **self.httprequest.form,
- **self.httprequest.files
- }
- return params
-
- def get_json_data(self):
- return json.loads(self.httprequest.get_data(as_text=True))
-
- def _get_profiler_context_manager(self):
- """
- Get a profiler when the profiling is enabled and the requested
- URL is profile-safe. Otherwise, get a context-manager that does
- nothing.
- """
- if self.session.profile_session and self.db:
- if self.session.profile_expiration < str(datetime.now()):
- # avoid having session profiling for too long if user forgets to disable profiling
- self.session.profile_session = None
- _logger.warning("Profiling expiration reached, disabling profiling")
- elif 'set_profiling' in self.httprequest.path:
- _logger.debug("Profiling disabled on set_profiling route")
- elif self.httprequest.path.startswith('/websocket'):
- _logger.debug("Profiling disabled for websocket")
- elif odoo.evented:
- # only longpolling should be in a evented server, but this is an additional safety
- _logger.debug("Profiling disabled for evented server")
- else:
- try:
- return profiler.Profiler(
- db=self.db,
- description=self.httprequest.full_path,
- profile_session=self.session.profile_session,
- collectors=self.session.profile_collectors,
- params=self.session.profile_params,
- )
- except Exception:
- _logger.exception("Failure during Profiler creation")
- self.session.profile_session = None
-
- return contextlib.nullcontext()
-
- def _inject_future_response(self, response):
- response.headers.extend(self.future_response.headers)
- return response
-
- def make_response(self, data, headers=None, cookies=None, status=200):
- """ Helper for non-HTML responses, or HTML responses with custom
- response headers or cookies.
-
- While handlers can just return the HTML markup of a page they want to
- send as a string if non-HTML data is returned they need to create a
- complete response object, or the returned data will not be correctly
- interpreted by the clients.
-
- :param str data: response body
- :param int status: http status code
- :param headers: HTTP headers to set on the response
- :type headers: ``[(name, value)]``
- :param collections.abc.Mapping cookies: cookies to set on the client
- :returns: a response object.
- :rtype: :class:`~odoo.http.Response`
- """
- response = Response(data, status=status, headers=headers)
- if cookies:
- for k, v in cookies.items():
- response.set_cookie(k, v)
- return response
-
- def make_json_response(self, data, headers=None, cookies=None, status=200):
- """ Helper for JSON responses, it json-serializes ``data`` and
- sets the Content-Type header accordingly if none is provided.
-
- :param data: the data that will be json-serialized into the response body
- :param int status: http status code
- :param List[(str, str)] headers: HTTP headers to set on the response
- :param collections.abc.Mapping cookies: cookies to set on the client
- :rtype: :class:`~odoo.http.Response`
- """
- data = json.dumps(data, ensure_ascii=False, default=json_default)
-
- headers = werkzeug.datastructures.Headers(headers)
- headers['Content-Length'] = len(data)
- if 'Content-Type' not in headers:
- headers['Content-Type'] = 'application/json; charset=utf-8'
-
- return self.make_response(data, headers.to_wsgi_list(), cookies, status)
-
- def not_found(self, description=None):
- """ Shortcut for a `HTTP 404
- <http://tools.ietf.org/html/rfc7231#section-6.5.4>`_ (Not Found)
- response
- """
- return NotFound(description)
-
- def redirect(self, location, code=303, local=True):
- # compatibility, Werkzeug support URL as location
- if isinstance(location, URL):
- location = location.to_url()
- if local:
- location = '/' + url_parse(location).replace(scheme='', netloc='').to_url().lstrip('/\\')
- if self.db:
- return self.env['ir.http']._redirect(location, code)
- return werkzeug.utils.redirect(location, code, Response=Response)
-
- def redirect_query(self, location, query=None, code=303, local=True):
- if query:
- location += '?' + url_encode(query)
- return self.redirect(location, code=code, local=local)
-
- def render(self, template, qcontext=None, lazy=True, **kw):
- """ Lazy render of a QWeb template.
-
- The actual rendering of the given template will occur at then end of
- the dispatching. Meanwhile, the template and/or qcontext can be
- altered or even replaced by a static response.
-
- :param str template: template to render
- :param dict qcontext: Rendering context to use
- :param bool lazy: whether the template rendering should be deferred
- until the last possible moment
- :param dict kw: forwarded to werkzeug's Response object
- """
- response = Response(template=template, qcontext=qcontext, **kw)
- if not lazy:
- return response.render()
- return response
-
- def reroute(self, path, query_string=None):
- """
- Rewrite the current request URL using the new path and query
- string. This act as a light redirection, it does not return a
- 3xx responses to the browser but still change the current URL.
- """
- # WSGI encoding dance https://peps.python.org/pep-3333/#unicode-issues
- if isinstance(path, str):
- path = path.encode('utf-8')
- path = path.decode('latin1', 'replace')
-
- if query_string is None:
- query_string = request.httprequest.environ['QUERY_STRING']
-
- # Change the WSGI environment
- environ = self.httprequest._HTTPRequest__environ.copy()
- environ['PATH_INFO'] = path
- environ['QUERY_STRING'] = query_string
- environ['RAW_URI'] = f'{path}?{query_string}'
- # REQUEST_URI left as-is so it still contains the original URI
-
- # Create and expose a new request from the modified WSGI env
- httprequest = HTTPRequest(environ)
- threading.current_thread().url = httprequest.url
- self.httprequest = httprequest
-
- def _save_session(self):
- """ Save a modified session on disk. """
- sess = self.session
-
- if not sess.can_save:
- return
-
- if sess.should_rotate:
- root.session_store.rotate(sess, self.env) # it saves
- elif sess.is_dirty:
- root.session_store.save(sess)
-
- cookie_sid = self.cookies.get('session_id')
- if sess.is_dirty or cookie_sid != sess.sid:
- self.future_response.set_cookie('session_id', sess.sid, max_age=get_session_max_inactivity(self.env), httponly=True)
-
- def _set_request_dispatcher(self, rule):
- routing = rule.endpoint.routing
- dispatcher_cls = _dispatchers[routing['type']]
- if (not is_cors_preflight(self, rule.endpoint)
- and not dispatcher_cls.is_compatible_with(self)):
- compatible_dispatchers = [
- disp.routing_type
- for disp in _dispatchers.values()
- if disp.is_compatible_with(self)
- ]
- raise BadRequest(f"Request inferred type is compatible with {compatible_dispatchers} but {routing['routes'][0]!r} is type={routing['type']!r}.")
- self.dispatcher = dispatcher_cls(self)
-
- # =====================================================
- # Routing
- # =====================================================
- def _serve_static(self):
- """ Serve a static file from the file system. """
- module, _, path = self.httprequest.path[1:].partition('/static/')
- try:
- directory = root.statics[module]
- filepath = werkzeug.security.safe_join(directory, path)
- debug = (
- 'assets' in self.session.debug and
- ' wkhtmltopdf ' not in self.httprequest.user_agent.string
- )
- res = Stream.from_path(filepath, public=True).get_response(
- max_age=0 if debug else STATIC_CACHE,
- content_security_policy=None,
- )
- root.set_csp(res)
- return res
- except KeyError:
- raise NotFound(f'Module "{module}" not found.\n')
- except OSError: # cover both missing file and invalid permissions
- raise NotFound(f'File "{path}" not found in module {module}.\n')
-
- def _serve_nodb(self):
- """
- Dispatch the request to its matching controller in a
- database-free environment.
- """
- router = root.nodb_routing_map.bind_to_environ(self.httprequest.environ)
- rule, args = router.match(return_rule=True)
- self._set_request_dispatcher(rule)
- self.dispatcher.pre_dispatch(rule, args)
- response = self.dispatcher.dispatch(rule.endpoint, args)
- self.dispatcher.post_dispatch(response)
- return response
-
- def _serve_db(self):
- """
- Prepare the user session and load the ORM before forwarding the
- request to ``_serve_ir_http``.
- """
- cr_readwrite = None
- rule = None
- args = None
- not_found = None
-
- # reuse the same cursor for building+checking the registry and
- # for matching the controller endpoint
- try:
- self.registry, cr_readwrite = self._open_registry()
- threading.current_thread().dbname = self.registry.db_name
- self.env = odoo.api.Environment(cr_readwrite, self.session.uid, self.session.context)
- try:
- rule, args = self.registry['ir.http']._match(self.httprequest.path)
- except NotFound as not_found_exc:
- not_found = not_found_exc
- finally:
- if cr_readwrite is not None:
- cr_readwrite.close()
-
- if not_found:
- # no controller endpoint matched -> fallback or 404
- return self._transactioning(
- functools.partial(self._serve_ir_http_fallback, not_found),
- readonly=True,
- )
-
- # a controller endpoint matched -> dispatch it the request
- self._set_request_dispatcher(rule)
- readonly = rule.endpoint.routing['readonly']
- if callable(readonly):
- readonly = readonly(rule.endpoint.func.__self__)
- return self._transactioning(
- functools.partial(self._serve_ir_http, rule, args),
- readonly=readonly,
- )
-
- def _serve_ir_http_fallback(self, not_found):
- """
- Called when no controller match the request path. Delegate to
- ``ir.http._serve_fallback`` to give modules the opportunity to
- find an alternative way to serve the request. In case no module
- provided a response, a generic 404 - Not Found page is returned.
- """
- self.params = self.get_http_params()
- response = self.registry['ir.http']._serve_fallback()
- if response:
- self.registry['ir.http']._post_dispatch(response)
- return response
-
- no_fallback = NotFound()
- no_fallback.__context__ = not_found # During handling of {not_found}, {no_fallback} occurred:
- no_fallback.error_response = self.registry['ir.http']._handle_error(no_fallback)
- raise no_fallback
-
- def _serve_ir_http(self, rule, args):
- """
- Called when a controller match the request path. Delegate to
- ``ir.http`` to serve a response.
- """
- self.registry['ir.http']._authenticate(rule.endpoint)
- self.registry['ir.http']._pre_dispatch(rule, args)
- response = self.dispatcher.dispatch(rule.endpoint, args)
- self.registry['ir.http']._post_dispatch(response)
- return response
-
- def _transactioning(self, func, readonly):
- """
- Call ``func`` within a new SQL transaction.
-
- If ``func`` performs a write query (insert/update/delete) on a
- read-only transaction, the transaction is rolled back, and
- ``func`` is called again in a read-write transaction.
-
- Other errors are handled by ``ir.http._handle_error`` within
- the same transaction.
-
- Note: This function does not reset any state set on ``request``
- and ``request.env`` upon returning. Therefore, any recordset
- set on request during one transaction WILL NOT be usable inside
- the following transactions unless the recordset is reset with
- ``with_env(request.env)``. This is especially a concern between
- ``_match`` and other ``ir.http`` methods, as ``_match`` is
- called inside its own dedicated transaction.
- """
- for readonly_cr in (True, False) if readonly else (False,):
- threading.current_thread().cursor_mode = (
- 'ro' if readonly_cr
- else 'ro->rw' if readonly
- else 'rw'
- )
-
- with contextlib.closing(self.registry.cursor(readonly=readonly_cr)) as cr:
- self.env = self.env(cr=cr)
- try:
- return service_model.retrying(func, env=self.env)
- except psycopg2.errors.ReadOnlySqlTransaction as exc:
- _logger.warning("%s, retrying with a read/write cursor", exc.args[0].rstrip(), exc_info=True)
- continue
- except Exception as exc:
- if isinstance(exc, HTTPException) and exc.code is None:
- raise # bubble up to odoo.http.Application.__call__
- if 'werkzeug' in config['dev_mode'] and self.dispatcher.routing_type != 'json':
- raise # bubble up to werkzeug.debug.DebuggedApplication
- if not hasattr(exc, 'error_response'):
- exc.error_response = self.registry['ir.http']._handle_error(exc)
- raise
-
-
- # =========================================================
- # Core type-specialized dispatchers
- # =========================================================
-
- _dispatchers = {}
-
- class Dispatcher(ABC):
- routing_type: str
-
- @classmethod
- def __init_subclass__(cls):
- super().__init_subclass__()
- _dispatchers[cls.routing_type] = cls
-
- def __init__(self, request):
- self.request = request
-
- @classmethod
- @abstractmethod
- def is_compatible_with(cls, request):
- """
- Determine if the current request is compatible with this
- dispatcher.
- """
-
- def pre_dispatch(self, rule, args):
- """
- Prepare the system before dispatching the request to its
- controller. This method is often overridden in ir.http to
- extract some info from the request query-string or headers and
- to save them in the session or in the context.
- """
- routing = rule.endpoint.routing
- self.request.session.can_save = routing.get('save_session', True)
-
- set_header = self.request.future_response.headers.set
- cors = routing.get('cors')
- if cors:
- set_header('Access-Control-Allow-Origin', cors)
- set_header('Access-Control-Allow-Methods', (
- 'POST' if routing['type'] == 'json'
- else ', '.join(routing['methods'] or ['GET', 'POST'])
- ))
-
- if cors and self.request.httprequest.method == 'OPTIONS':
- set_header('Access-Control-Max-Age', CORS_MAX_AGE)
- set_header('Access-Control-Allow-Headers',
- 'Origin, X-Requested-With, Content-Type, Accept, Authorization')
- werkzeug.exceptions.abort(Response(status=204))
-
- if 'max_content_length' in routing:
- max_content_length = routing['max_content_length']
- if callable(max_content_length):
- max_content_length = max_content_length(rule.endpoint.func.__self__)
- self.request.httprequest.max_content_length = max_content_length
-
- @abstractmethod
- def dispatch(self, endpoint, args):
- """
- Extract the params from the request's body and call the
- endpoint. While it is preferred to override ir.http._pre_dispatch
- and ir.http._post_dispatch, this method can be override to have
- a tight control over the dispatching.
- """
-
- def post_dispatch(self, response):
- """
- Manipulate the HTTP response to inject various headers, also
- save the session when it is dirty.
- """
- self.request._save_session()
- self.request._inject_future_response(response)
- root.set_csp(response)
-
- @abstractmethod
- def handle_error(self, exc: Exception) -> collections.abc.Callable:
- """
- Transform the exception into a valid HTTP response. Called upon
- any exception while serving a request.
- """
-
-
- class HttpDispatcher(Dispatcher):
- routing_type = 'http'
-
- @classmethod
- def is_compatible_with(cls, request):
- return True
-
- def dispatch(self, endpoint, args):
- """
- Perform http-related actions such as deserializing the request
- body and query-string and checking cors/csrf while dispatching a
- request to a ``type='http'`` route.
-
- See :meth:`~odoo.http.Response.load` method for the compatible
- endpoint return types.
- """
- self.request.params = dict(self.request.get_http_params(), **args)
-
- # Check for CSRF token for relevant requests
- if self.request.httprequest.method not in CSRF_FREE_METHODS and endpoint.routing.get('csrf', True):
- if not self.request.db:
- return self.request.redirect('/web/database/selector')
-
- token = self.request.params.pop('csrf_token', None)
- if not self.request.validate_csrf(token):
- if token is not None:
- _logger.warning("CSRF validation failed on path '%s'", self.request.httprequest.path)
- else:
- _logger.warning(MISSING_CSRF_WARNING, request.httprequest.path)
- raise werkzeug.exceptions.BadRequest('Session expired (invalid CSRF token)')
-
- if self.request.db:
- return self.request.registry['ir.http']._dispatch(endpoint)
- else:
- return endpoint(**self.request.params)
-
- def handle_error(self, exc: Exception) -> collections.abc.Callable:
- """
- Handle any exception that occurred while dispatching a request
- to a `type='http'` route. Also handle exceptions that occurred
- when no route matched the request path, when no fallback page
- could be delivered and that the request ``Content-Type`` was not
- json.
-
- :param Exception exc: the exception that occurred.
- :returns: a WSGI application
- """
- if isinstance(exc, SessionExpiredException):
- session = self.request.session
- was_connected = session.uid is not None
- session.logout(keep_db=True)
- response = self.request.redirect_query('/web/login', {'redirect': self.request.httprequest.full_path})
- if was_connected:
- root.session_store.rotate(session, self.request.env)
- response.set_cookie('session_id', session.sid, max_age=get_session_max_inactivity(self.env), httponly=True)
- return response
-
- return (exc if isinstance(exc, HTTPException)
- else Forbidden(exc.args[0]) if isinstance(exc, (AccessDenied, AccessError))
- else BadRequest(exc.args[0]) if isinstance(exc, UserError)
- else InternalServerError() # hide the real error
- )
-
-
- class JsonRPCDispatcher(Dispatcher):
- routing_type = 'json'
-
- def __init__(self, request):
- super().__init__(request)
- self.jsonrequest = {}
- self.request_id = None
-
- @classmethod
- def is_compatible_with(cls, request):
- return request.httprequest.mimetype in JSON_MIMETYPES
-
- def dispatch(self, endpoint, args):
- """
- `JSON-RPC 2 <http://www.jsonrpc.org/specification>`_ over HTTP.
-
- Our implementation differs from the specification on two points:
-
- 1. The ``method`` member of the JSON-RPC request payload is
- ignored as the HTTP path is already used to route the request
- to the controller.
- 2. We only support parameter structures by-name, i.e. the
- ``params`` member of the JSON-RPC request payload MUST be a
- JSON Object and not a JSON Array.
-
- In addition, it is possible to pass a context that replaces
- the session context via a special ``context`` argument that is
- removed prior to calling the endpoint.
-
- Successful request::
-
- --> {"jsonrpc": "2.0", "method": "call", "params": {"arg1": "val1" }, "id": null}
-
- <-- {"jsonrpc": "2.0", "result": { "res1": "val1" }, "id": null}
-
- Request producing a error::
-
- --> {"jsonrpc": "2.0", "method": "call", "params": {"arg1": "val1" }, "id": null}
-
- <-- {"jsonrpc": "2.0", "error": {"code": 1, "message": "End user error message.", "data": {"code": "codestring", "debug": "traceback" } }, "id": null}
-
- """
- try:
- self.jsonrequest = self.request.get_json_data()
- self.request_id = self.jsonrequest.get('id')
- except ValueError:
- # must use abort+Response to bypass handle_error
- werkzeug.exceptions.abort(Response("Invalid JSON data", status=400))
- except AttributeError:
- # must use abort+Response to bypass handle_error
- werkzeug.exceptions.abort(Response("Invalid JSON-RPC data", status=400))
-
- self.request.params = dict(self.jsonrequest.get('params', {}), **args)
-
- if self.request.db:
- result = self.request.registry['ir.http']._dispatch(endpoint)
- else:
- result = endpoint(**self.request.params)
- return self._response(result)
-
- def handle_error(self, exc: Exception) -> collections.abc.Callable:
- """
- Handle any exception that occurred while dispatching a request to
- a `type='json'` route. Also handle exceptions that occurred when
- no route matched the request path, that no fallback page could
- be delivered and that the request ``Content-Type`` was json.
-
- :param exc: the exception that occurred.
- :returns: a WSGI application
- """
- error = {
- 'code': 200, # this code is the JSON-RPC level code, it is
- # distinct from the HTTP status code. This
- # code is ignored and the value 200 (while
- # misleading) is totally arbitrary.
- 'message': "Odoo Server Error",
- 'data': serialize_exception(exc),
- }
- if isinstance(exc, NotFound):
- error['code'] = 404
- error['message'] = "404: Not Found"
- elif isinstance(exc, SessionExpiredException):
- error['code'] = 100
- error['message'] = "Odoo Session Expired"
-
- return self._response(error=error)
-
- def _response(self, result=None, error=None):
- response = {'jsonrpc': '2.0', 'id': self.request_id}
- if error is not None:
- response['error'] = error
- if result is not None:
- response['result'] = result
-
- return self.request.make_json_response(response)
-
-
- # =========================================================
- # WSGI Entry Point
- # =========================================================
-
- class Application:
- """ Odoo WSGI application """
- # See also: https://www.python.org/dev/peps/pep-3333
-
- @lazy_property
- def statics(self):
- """
- Map module names to their absolute ``static`` path on the file
- system.
- """
- mod2path = {}
- for addons_path in odoo.addons.__path__:
- for module in os.listdir(addons_path):
- manifest = get_manifest(module)
- static_path = opj(addons_path, module, 'static')
- if (manifest
- and (manifest['installable'] or manifest['assets'])
- and os.path.isdir(static_path)):
- mod2path[module] = static_path
- return mod2path
-
- def get_static_file(self, url, host=''):
- """
- Get the full-path of the file if the url resolves to a local
- static file, otherwise return None.
-
- Without the second host parameters, ``url`` must be an absolute
- path, others URLs are considered faulty.
-
- With the second host parameters, ``url`` can also be a full URI
- and the authority found in the URL (if any) is validated against
- the given ``host``.
- """
-
- netloc, path = urlparse(url)[1:3]
- try:
- path_netloc, module, static, resource = path.split('/', 3)
- except ValueError:
- return None
-
- if ((netloc and netloc != host) or (path_netloc and path_netloc != host)):
- return None
-
- if (module not in self.statics or static != 'static' or not resource):
- return None
-
- try:
- return file_path(f'{module}/static/{resource}')
- except FileNotFoundError:
- return None
-
- @lazy_property
- def nodb_routing_map(self):
- nodb_routing_map = werkzeug.routing.Map(strict_slashes=False, converters=None)
- for url, endpoint in _generate_routing_rules([''] + odoo.conf.server_wide_modules, nodb_only=True):
- routing = submap(endpoint.routing, ROUTING_KEYS)
- if routing['methods'] is not None and 'OPTIONS' not in routing['methods']:
- routing['methods'] = [*routing['methods'], 'OPTIONS']
- rule = werkzeug.routing.Rule(url, endpoint=endpoint, **routing)
- rule.merge_slashes = False
- nodb_routing_map.add(rule)
-
- return nodb_routing_map
-
- @lazy_property
- def session_store(self):
- path = odoo.tools.config.session_dir
- _logger.debug('HTTP sessions stored in: %s', path)
- return FilesystemSessionStore(path, session_class=Session, renew_missing=True)
-
- def get_db_router(self, db):
- if not db:
- return self.nodb_routing_map
- return request.env['ir.http'].routing_map()
-
- @lazy_property
- def geoip_city_db(self):
- try:
- return geoip2.database.Reader(config['geoip_city_db'])
- except (OSError, maxminddb.InvalidDatabaseError):
- _logger.debug(
- "Couldn't load Geoip City file at %s. IP Resolver disabled.",
- config['geoip_city_db'], exc_info=True
- )
- raise
-
- @lazy_property
- def geoip_country_db(self):
- try:
- return geoip2.database.Reader(config['geoip_country_db'])
- except (OSError, maxminddb.InvalidDatabaseError) as exc:
- _logger.debug("Couldn't load Geoip Country file (%s). Fallbacks on Geoip City.", exc,)
- raise
-
- def set_csp(self, response):
- headers = response.headers
- headers['X-Content-Type-Options'] = 'nosniff'
-
- if 'Content-Security-Policy' in headers:
- return
-
- if not headers.get('Content-Type', '').startswith('image/'):
- return
-
- headers['Content-Security-Policy'] = "default-src 'none'"
-
- def __call__(self, environ, start_response):
- """
- WSGI application entry point.
-
- :param dict environ: container for CGI environment variables
- such as the request HTTP headers, the source IP address and
- the body as an io file.
- :param callable start_response: function provided by the WSGI
- server that this application must call in order to send the
- HTTP response status line and the response headers.
- """
- current_thread = threading.current_thread()
- current_thread.query_count = 0
- current_thread.query_time = 0
- current_thread.perf_t0 = time.time()
- current_thread.cursor_mode = None
- if hasattr(current_thread, 'dbname'):
- del current_thread.dbname
- if hasattr(current_thread, 'uid'):
- del current_thread.uid
-
- if odoo.tools.config['proxy_mode'] and environ.get("HTTP_X_FORWARDED_HOST"):
- # The ProxyFix middleware has a side effect of updating the
- # environ, see https://github.com/pallets/werkzeug/pull/2184
- def fake_app(environ, start_response):
- return []
- def fake_start_response(status, headers):
- return
- ProxyFix(fake_app)(environ, fake_start_response)
-
- with HTTPRequest(environ) as httprequest:
- request = Request(httprequest)
- _request_stack.push(request)
-
- try:
- request._post_init()
- current_thread.url = httprequest.url
-
- if self.get_static_file(httprequest.path):
- response = request._serve_static()
- elif request.db:
- try:
- with request._get_profiler_context_manager():
- response = request._serve_db()
- except RegistryError as e:
- _logger.warning("Database or registry unusable, trying without", exc_info=e.__cause__)
- request.db = None
- request.session.logout()
- if (httprequest.path.startswith('/odoo/')
- or httprequest.path in (
- '/odoo', '/web', '/web/login', '/test_http/ensure_db',
- )):
- # ensure_db() protected routes, remove ?db= from the query string
- args_nodb = request.httprequest.args.copy()
- args_nodb.pop('db', None)
- request.reroute(httprequest.path, url_encode(args_nodb))
- response = request._serve_nodb()
- else:
- response = request._serve_nodb()
- return response(environ, start_response)
-
- except Exception as exc:
- # Valid (2xx/3xx) response returned via werkzeug.exceptions.abort.
- if isinstance(exc, HTTPException) and exc.code is None:
- response = exc.get_response()
- HttpDispatcher(request).post_dispatch(response)
- return response(environ, start_response)
-
- # Logs the error here so the traceback starts with ``__call__``.
- if hasattr(exc, 'loglevel'):
- _logger.log(exc.loglevel, exc, exc_info=getattr(exc, 'exc_info', None))
- elif isinstance(exc, HTTPException):
- pass
- elif isinstance(exc, SessionExpiredException):
- _logger.info(exc)
- elif isinstance(exc, (UserError, AccessError)):
- _logger.warning(exc)
- else:
- _logger.error("Exception during request handling.", exc_info=True)
-
- # Ensure there is always a WSGI handler attached to the exception.
- if not hasattr(exc, 'error_response'):
- exc.error_response = request.dispatcher.handle_error(exc)
-
- return exc.error_response(environ, start_response)
-
- finally:
- _request_stack.pop()
-
-
- root = Application()
|