1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038 |
- # 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 an ascii graph that
- shows the various processing layers each request passes through before
- ending at the @route decorated endpoint. Hopefully, this graph and the
- attached function descriptions will help you understand this module.
- Here be dragons:
- Application.__call__
- +-> Request._serve_static
- |
- +-> Request._serve_nodb
- | -> App.nodb_routing_map.match
- | -> Dispatcher.pre_dispatch
- | -> Dispatcher.dispatch
- | -> route_wrapper
- | -> endpoint
- | -> Dispatcher.post_dispatch
- |
- +-> Request._serve_db
- -> model.retrying
- -> Request._serve_ir_http
- -> env['ir.http']._match
- -> 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
- -> Dispatcher.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 session and initializes the ORM before
- forwarding the request to ``retrying`` and ``_serve_ir_http``.
- service.model.retrying
- Protect against SQL serialisation errors (when two different
- transactions write on the same record), when such an error occurs this
- function resets the session and the environment then re-dispatches the
- request.
- Request._serve_ir_http
- Delegate most of the effort to the ``ir.http`` abstract model which
- itself calls RequestDispatch back. ``ir.http`` grants modularity in
- the http stack. The notable difference with nodb is that there is an
- authentication layer and a mechanism to serve pages that are not
- accessible through controllers.
- 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.
- 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 cgi
- 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
- import zlib
- from abc import ABC, abstractmethod
- from datetime import datetime
- 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
- 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, date_utils, file_path, parse_version,
- profiler, submap, unique, ustr,)
- from .tools.geoipresolver import GeoIPResolver
- from .tools.func import filter_kwargs, lazy_property
- from .tools.mimetypes import guess_mimetype
- from .tools.misc import pickle
- from .tools._vendor import sessions
- from .tools._vendor.useragents import UserAgent
- _logger = logging.getLogger(__name__)
- # =========================================================
- # Lib fixes
- # =========================================================
- # Add potentially missing (older ubuntu) font mime types
- mimetypes.add_type('application/font-woff', '.woff')
- mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
- mimetypes.add_type('application/x-font-ttf', '.ttf')
- # Add potentially wrong (detected on windows) svg mime types
- mimetypes.add_type('image/svg+xml', '.svg')
- # To remove when corrected in Babel
- babel.core.LOCALE_ALIASES['nb'] = 'nb_NO'
- # =========================================================
- # 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,
- }
- # 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/16.0/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 SessionExpiredException(Exception):
- pass
- def content_disposition(filename):
- return "attachment; filename*=UTF-8''{}".format(
- url_quote(filename, safe='')
- )
- 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 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': ustr(exception),
- 'arguments': exception.args,
- 'context': getattr(exception, 'context', {}),
- }
- # =========================================================
- # File Streaming
- # =========================================================
- def send_file(filepath_or_fp, mimetype=None, as_attachment=False, filename=None, mtime=None,
- add_etags=True, cache_timeout=STATIC_CACHE, conditional=True):
- warnings.warn('odoo.http.send_file is deprecated, please use odoo.http.Stream instead.', DeprecationWarning, stacklevel=2)
- return _send_file(
- filepath_or_fp,
- request.httprequest.environ,
- mimetype=mimetype,
- as_attachment=as_attachment,
- download_name=filename,
- last_modified=mtime,
- etag=add_etags,
- max_age=cache_timeout,
- response_class=Response,
- conditional=conditional
- )
- 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`:,
- :meth:`~from_attachment`: 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
- def __init__(self, **kwargs):
- self.__dict__.update(kwargs)
- @classmethod
- def from_path(cls, path, filter_ext=('',)):
- """ Create a :class:`~Stream`: from an addon resource. """
- path = file_path(path, filter_ext)
- check = adler32(path.encode())
- stat = os.stat(path)
- return cls(
- type='path',
- path=path,
- 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,
- )
- @classmethod
- def from_attachment(cls, attachment):
- """ Create a :class:`~Stream`: from an ir.attachment record. """
- attachment.ensure_one()
- self = cls(
- mimetype=attachment.mimetype,
- download_name=attachment.name,
- conditional=True,
- etag=attachment.checksum,
- )
- if attachment.store_fname:
- self.type = 'path'
- self.path = werkzeug.security.safe_join(
- os.path.abspath(config.filestore(request.db)),
- attachment.store_fname
- )
- stat = os.stat(self.path)
- self.last_modified = stat.st_mtime
- self.size = stat.st_size
- elif attachment.db_datas:
- self.type = 'data'
- self.data = attachment.raw
- self.last_modified = attachment['__last_update']
- self.size = len(self.data)
- elif attachment.url:
- # When the URL targets a file located in an addon, assume it
- # is a path to the resource. It saves an indirection and
- # stream the file right away.
- static_path = root.get_static_file(
- attachment.url,
- host=request.httprequest.environ.get('HTTP_HOST', '')
- )
- if static_path:
- self = cls.from_path(static_path)
- else:
- self.type = 'url'
- self.url = attachment.url
- else:
- self.type = 'data'
- self.data = b''
- self.size = 0
- return self
- @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['__last_update'] if record._log_access else None,
- size=len(data),
- )
- 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, **send_file_kwargs):
- """
- Create the corresponding :class:`~Response` for the current stream.
- :param bool as_attachment: Indicate to the browser that it
- should offer to save the file instead of displaying it.
- :param bool 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 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':
- 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':
- return _send_file(BytesIO(self.data), **send_file_kwargs)
- # 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 immutable and res.cache_control:
- res.cache_control["immutable"] = None # None sets the directive
- 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'
- 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.
- * ``'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.
- """
- 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, list) 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': [],
- 'readonly': False,
- }
- 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)
- # Ensure "type" is defined on each method's own routing,
- # also ensure overrides don't change the routing type.
- 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'{cls.__module__}.{cls.__name__}.{method_name}', routing_type)
- submethod.original_routing['type'] = routing_type
- 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)
- # =========================================================
- # Session
- # =========================================================
- class FilesystemSessionStore(sessions.FilesystemSessionStore):
- """ Place where to load and save session objects. """
- def get_session_filename(self, sid):
- # scatter sessions across 256 directories
- 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)
- class Session(collections.abc.MutableMapping):
- """ Structure containing data persisted across requests. """
- __slots__ = ('can_save', '_Session__data', 'is_dirty', 'is_explicit', '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_explicit = False
- self.is_new = new
- self.should_rotate = False
- self.sid = sid
- #
- # MutableMapping implementation with DocDict-like extension
- #
- def __getitem__(self, item):
- if item == 'geoip':
- warnings.warn('request.session.geoip have been moved to request.geoip', DeprecationWarning)
- return request.geoip if request else {}
- return self.__data[item]
- def __setitem__(self, item, value):
- value = pickle.loads(pickle.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, login=None, password=None):
- """
- Authenticate the current user with the given db, login and
- password. 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)
- pre_uid = registry['res.users'].authenticate(dbname, login, password, wsgienv)
- self.uid = None
- self.pre_login = 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 not user._mfa_url():
- self.finalize(env)
- if request and request.session is self and request.db == dbname:
- # Like update_env(user=request.session.uid) but works when uid is None
- request.env = odoo.api.Environment(request.env.cr, self.uid, self.context)
- request.update_context(**self.context)
- return pre_uid
- 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
- def touch(self):
- self.is_dirty = True
- # =========================================================
- # 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)
- 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=None, path='/', domain=None, secure=False, httponly=False, samesite=None, cookie_type='required'):
- if request.db and not request.env['ir.http']._is_allowed_cookie(cookie_type):
- expires = 0
- 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=None, path='/', domain=None, secure=False, httponly=False, samesite=None, cookie_type='required'):
- if request.db and not request.env['ir.http']._is_allowed_cookie(cookie_type):
- expires = 0
- 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.registry = None
- self.env = None
- def _post_init(self):
- self.session, self.db = self._get_session_and_dbname()
- def _get_session_and_dbname(self):
- # The session is explicit when it comes from the query-string or
- # the header. It is implicit when it comes from the cookie or
- # that is does not exist yet. The explicit session should be
- # used in this request only, it should not be saved on the
- # response cookie.
- sid = (self.httprequest.args.get('session_id')
- or self.httprequest.headers.get("X-Openerp-Session-Id"))
- if sid:
- is_explicit = True
- else:
- sid = self.httprequest.cookies.get('session_id')
- is_explicit = False
- if sid is None:
- session = root.session_store.new()
- else:
- session = root.session_store.get(sid)
- session.sid = sid # in case the session was not persisted
- session.is_explicit = is_explicit
- 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
- # =====================================================
- # 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
- @property
- def geoip(self):
- """
- Get the remote address geolocalisation.
- When geolocalization is successful, the return value is a
- dictionary whose format is:
- {'city': str, 'country_code': str, 'country_name': str,
- 'latitude': float, 'longitude': float, 'region': str,
- 'time_zone': str}
- When geolocalization fails, an empty dict is returned.
- """
- if '_geoip' not in self.session:
- was_dirty = self.session.is_dirty
- self.session._geoip = (self.registry['ir.http']._geoip_resolve()
- if self.db else self._geoip_resolve())
- self.session.is_dirty = was_dirty
- return self.session._geoip
- @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
- # =====================================================
- # 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 _geoip_resolve(self):
- if not (root.geoip_resolver and self.httprequest.remote_addr):
- return {}
- return root.geoip_resolver.resolve(self.httprequest.remote_addr) or {}
- 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
- }
- params.pop('session_id', None)
- 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=date_utils.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 _save_session(self):
- """ Save a modified session on disk. """
- sess = self.session
- if not sess.can_save:
- return
- if sess.should_rotate:
- sess['_geoip'] = self.geoip
- root.session_store.rotate(sess, self.env) # it saves
- elif sess.is_dirty:
- sess['_geoip'] = self.geoip
- root.session_store.save(sess)
- # We must not set the cookie if the session id was specified
- # using a http header or a GET parameter.
- # There are two reasons to this:
- # - When using one of those two means we consider that we are
- # overriding the cookie, which means creating a new session on
- # top of an already existing session and we don't want to
- # create a mess with the 'normal' session (the one using the
- # cookie). That is a special feature of the Javascript Session.
- # - It could allow session fixation attacks.
- cookie_sid = self.httprequest.cookies.get('session_id')
- if not sess.is_explicit and (sess.is_dirty or cookie_sid != sess.sid):
- self.future_response.set_cookie('session_id', sess.sid, max_age=SESSION_LIFETIME, 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)
- return Stream.from_path(filepath).get_response(
- max_age=0 if 'assets' in self.session.debug else STATIC_CACHE,
- )
- 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``.
- """
- try:
- self.registry = Registry(self.db).check_signaling()
- except (AttributeError, psycopg2.OperationalError, psycopg2.ProgrammingError):
- # psycopg2 error or attribute error while constructing
- # the registry. That means either
- # - the database probably does not exists anymore, or
- # - the database is corrupted, or
- # - the database version doesn't match the server version.
- # So remove the database from the cookie
- self.db = None
- self.session.db = None
- root.session_store.save(self.session)
- if request.httprequest.path == '/web':
- # Internal Server Error
- raise
- else:
- return self._serve_nodb()
- with contextlib.closing(self.registry.cursor()) as cr:
- self.env = odoo.api.Environment(cr, self.session.uid, self.session.context)
- threading.current_thread().uid = self.env.uid
- try:
- return service_model.retrying(self._serve_ir_http, self.env)
- except Exception as exc:
- if isinstance(exc, HTTPException) and exc.code is None:
- raise # bubble up to odoo.http.Application.__call__
- exc.error_response = self.registry['ir.http']._handle_error(exc)
- raise
- def _serve_ir_http(self):
- """
- Delegate most of the processing to the ir.http model that is
- extensible by applications.
- """
- ir_http = self.registry['ir.http']
- try:
- rule, args = ir_http._match(self.httprequest.path)
- except NotFound:
- self.params = self.get_http_params()
- response = ir_http._serve_fallback()
- if response:
- self.dispatcher.post_dispatch(response)
- return response
- raise
- self._set_request_dispatcher(rule)
- ir_http._authenticate(rule.endpoint)
- ir_http._pre_dispatch(rule, args)
- response = self.dispatcher.dispatch(rule.endpoint, args)
- # the registry can have been reniewed by dispatch
- self.registry['ir.http']._post_dispatch(response)
- return response
- # =========================================================
- # 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))
- @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 not session.is_explicit and was_connected:
- root.session_store.rotate(session, self.request.env)
- response.set_cookie('session_id', session.sid, max_age=SESSION_LIFETIME, 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": {"context": {}, "arg1": "val1" }, "id": null}
- <-- {"jsonrpc": "2.0", "result": { "res1": "val1" }, "id": null}
- Request producing a error::
- --> {"jsonrpc": "2.0", "method": "call", "params": {"context": {}, "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 as exc:
- # must use abort+Response to bypass handle_error
- werkzeug.exceptions.abort(Response("Invalid JSON data", status=400))
- except AttributeError as exc:
- # 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)
- ctx = self.request.params.pop('context', None)
- if ctx is not None and self.request.db:
- self.request.update_context(**ctx)
- 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)
- @lazy_property
- def geoip_resolver(self):
- try:
- return GeoIPResolver.open(config.get('geoip_database'))
- except Exception as e:
- _logger.warning('Cannot load GeoIP: %s', e)
- def get_db_router(self, db):
- if not db:
- return self.nodb_routing_map
- return request.registry['ir.http'].routing_map()
- def set_csp(self, response):
- headers = response.headers
- if 'Content-Security-Policy' in headers:
- return
- mime, _params = cgi.parse_header(headers.get('Content-Type', ''))
- if not mime.startswith('image/'):
- return
- headers['Content-Security-Policy'] = "default-src 'none'"
- headers['X-Content-Type-Options'] = 'nosniff'
- 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()
- 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)
- 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)
- request = Request(httprequest)
- _request_stack.push(request)
- request._post_init()
- current_thread.url = httprequest.url
- try:
- if self.get_static_file(httprequest.path):
- response = request._serve_static()
- elif request.db:
- with request._get_profiler_context_manager():
- response = request._serve_db()
- 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, NotFound)):
- _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()
|