gooderp18绿色标准版
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2420 lines
93KB

  1. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  2. r"""\
  3. Odoo HTTP layer / WSGI application
  4. The main duty of this module is to prepare and dispatch all http
  5. requests to their corresponding controllers: from a raw http request
  6. arriving on the WSGI entrypoint to a :class:`~http.Request`: arriving at
  7. a module controller with a fully setup ORM available.
  8. Application developers mostly know this module thanks to the
  9. :class:`~odoo.http.Controller`: class and its companion the
  10. :func:`~odoo.http.route`: method decorator. Together they are used to
  11. register methods responsible of delivering web content to matching URLS.
  12. Those two are only the tip of the iceberg, below is a call graph that
  13. shows the various processing layers each request passes through before
  14. ending at the @route decorated endpoint. Hopefully, this call graph and
  15. the attached function descriptions will help you understand this module.
  16. Here be dragons:
  17. Application.__call__
  18. if path is like '/<module>/static/<path>':
  19. Request._serve_static
  20. elif not request.db:
  21. Request._serve_nodb
  22. App.nodb_routing_map.match
  23. Dispatcher.pre_dispatch
  24. Dispatcher.dispatch
  25. route_wrapper
  26. endpoint
  27. Dispatcher.post_dispatch
  28. else:
  29. Request._serve_db
  30. env['ir.http']._match
  31. if not match:
  32. Request._transactioning
  33. model.retrying
  34. env['ir.http']._serve_fallback
  35. env['ir.http']._post_dispatch
  36. else:
  37. Request._transactioning
  38. model.retrying
  39. env['ir.http']._authenticate
  40. env['ir.http']._pre_dispatch
  41. Dispatcher.pre_dispatch
  42. Dispatcher.dispatch
  43. env['ir.http']._dispatch
  44. route_wrapper
  45. endpoint
  46. env['ir.http']._post_dispatch
  47. Application.__call__
  48. WSGI entry point, it sanitizes the request, it wraps it in a werkzeug
  49. request and itself in an Odoo http request. The Odoo http request is
  50. exposed at ``http.request`` then it is forwarded to either
  51. ``_serve_static``, ``_serve_nodb`` or ``_serve_db`` depending on the
  52. request path and the presence of a database. It is also responsible of
  53. ensuring any error is properly logged and encapsuled in a HTTP error
  54. response.
  55. Request._serve_static
  56. Handle all requests to ``/<module>/static/<asset>`` paths, open the
  57. underlying file on the filesystem and stream it via
  58. :meth:``Request.send_file``
  59. Request._serve_nodb
  60. Handle requests to ``@route(auth='none')`` endpoints when the user is
  61. not connected to a database. It performs limited operations, just
  62. matching the auth='none' endpoint using the request path and then it
  63. delegates to Dispatcher.
  64. Request._serve_db
  65. Handle all requests that are not static when it is possible to connect
  66. to a database. It opens a registry on the database and then delegates
  67. most of the effort the the ``ir.http`` abstract model. This model acts
  68. as a module-aware middleware, its implementation in ``base`` is merely
  69. more than just delegating to Dispatcher.
  70. Request._transactioning & service.model.retrying
  71. Manage the cursor, the environment and exceptions that occured while
  72. executing the underlying function. They recover from various
  73. exceptions such as serialization errors and writes in read-only
  74. transactions. They catches all other exceptions and attach a http
  75. response to them (e.g. 500 - Internal Server Error)
  76. ir.http._match
  77. Match the controller endpoint that correspond to the request path.
  78. Beware that there is an important override for portal and website
  79. inside of the ``http_routing`` module.
  80. ir.http._serve_fallback
  81. Find alternative ways to serve a request when its path does not match
  82. any controller. The path could be matching an attachment URL, a blog
  83. page, etc.
  84. ir.http._authenticate
  85. Ensure the user on the current environment fulfill the requirement of
  86. ``@route(auth=...)``. Using the ORM outside of abstract models is
  87. unsafe prior of calling this function.
  88. ir.http._pre_dispatch/Dispatcher.pre_dispatch
  89. Prepare the system the handle the current request, often used to save
  90. some extra query-string parameters in the session (e.g. ?debug=1)
  91. ir.http._dispatch/Dispatcher.dispatch
  92. Deserialize the HTTP request body into ``request.params`` according to
  93. @route(type=...), call the controller endpoint, serialize its return
  94. value into an HTTP Response object.
  95. ir.http._post_dispatch/Dispatcher.post_dispatch
  96. Post process the response returned by the controller endpoint. Used to
  97. inject various headers such as Content-Security-Policy.
  98. ir.http._handle_error
  99. Not present in the call-graph, is called for un-managed exceptions (SE
  100. or RO) that occured inside of ``Request._transactioning``. It returns
  101. a http response that wraps the error that occured.
  102. route_wrapper, closure of the http.route decorator
  103. Sanitize the request parameters, call the route endpoint and
  104. optionally coerce the endpoint result.
  105. endpoint
  106. The @route(...) decorated controller method.
  107. """
  108. import base64
  109. import collections
  110. import collections.abc
  111. import contextlib
  112. import functools
  113. import glob
  114. import hashlib
  115. import hmac
  116. import inspect
  117. import json
  118. import logging
  119. import mimetypes
  120. import os
  121. import re
  122. import threading
  123. import time
  124. import traceback
  125. import warnings
  126. from abc import ABC, abstractmethod
  127. from datetime import datetime, timedelta
  128. from hashlib import sha512
  129. from io import BytesIO
  130. from os.path import join as opj
  131. from pathlib import Path
  132. from urllib.parse import urlparse
  133. from zlib import adler32
  134. import babel.core
  135. try:
  136. import geoip2.database
  137. import geoip2.models
  138. import geoip2.errors
  139. except ImportError:
  140. geoip2 = None
  141. try:
  142. import maxminddb
  143. except ImportError:
  144. maxminddb = None
  145. import psycopg2
  146. import werkzeug.datastructures
  147. import werkzeug.exceptions
  148. import werkzeug.local
  149. import werkzeug.routing
  150. import werkzeug.security
  151. import werkzeug.wrappers
  152. import werkzeug.wsgi
  153. from werkzeug.urls import URL, url_parse, url_encode, url_quote
  154. from werkzeug.exceptions import (HTTPException, BadRequest, Forbidden,
  155. NotFound, InternalServerError)
  156. try:
  157. from werkzeug.middleware.proxy_fix import ProxyFix as ProxyFix_
  158. ProxyFix = functools.partial(ProxyFix_, x_for=1, x_proto=1, x_host=1)
  159. except ImportError:
  160. from werkzeug.contrib.fixers import ProxyFix
  161. try:
  162. from werkzeug.utils import send_file as _send_file
  163. except ImportError:
  164. from .tools._vendor.send_file import send_file as _send_file
  165. import odoo
  166. from .exceptions import UserError, AccessError, AccessDenied
  167. from .modules.module import get_manifest
  168. from .modules.registry import Registry
  169. from .service import security, model as service_model
  170. from .tools import (config, consteq, file_path, get_lang, json_default,
  171. parse_version, profiler, unique, exception_to_unicode)
  172. from .tools.func import filter_kwargs, lazy_property
  173. from .tools.misc import submap
  174. from .tools._vendor import sessions
  175. from .tools._vendor.useragents import UserAgent
  176. _logger = logging.getLogger(__name__)
  177. # =========================================================
  178. # Const
  179. # =========================================================
  180. # The validity duration of a preflight response, one day.
  181. CORS_MAX_AGE = 60 * 60 * 24
  182. # The HTTP methods that do not require a CSRF validation.
  183. CSRF_FREE_METHODS = ('GET', 'HEAD', 'OPTIONS', 'TRACE')
  184. # The default csrf token lifetime, a salt against BREACH, one year
  185. CSRF_TOKEN_SALT = 60 * 60 * 24 * 365
  186. # The default lang to use when the browser doesn't specify it
  187. DEFAULT_LANG = 'en_US'
  188. # The dictionary to initialise a new session with.
  189. def get_default_session():
  190. return {
  191. 'context': {}, # 'lang': request.default_lang() # must be set at runtime
  192. 'db': None,
  193. 'debug': '',
  194. 'login': None,
  195. 'uid': None,
  196. 'session_token': None,
  197. '_trace': [],
  198. }
  199. DEFAULT_MAX_CONTENT_LENGTH = 128 * 1024 * 1024 # 128MiB
  200. # Two empty objects used when the geolocalization failed. They have the
  201. # sames attributes as real countries/cities except that accessing them
  202. # evaluates to None.
  203. if geoip2:
  204. GEOIP_EMPTY_COUNTRY = geoip2.models.Country({})
  205. GEOIP_EMPTY_CITY = geoip2.models.City({})
  206. # The request mimetypes that transport JSON in their body.
  207. JSON_MIMETYPES = ('application/json', 'application/json-rpc')
  208. MISSING_CSRF_WARNING = """\
  209. No CSRF validation token provided for path %r
  210. Odoo URLs are CSRF-protected by default (when accessed with unsafe
  211. HTTP methods). See
  212. https://www.odoo.com/documentation/master/developer/reference/addons/http.html#csrf
  213. for more details.
  214. * if this endpoint is accessed through Odoo via py-QWeb form, embed a CSRF
  215. token in the form, Tokens are available via `request.csrf_token()`
  216. can be provided through a hidden input and must be POST-ed named
  217. `csrf_token` e.g. in your form add:
  218. <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
  219. * if the form is generated or posted in javascript, the token value is
  220. available as `csrf_token` on `web.core` and as the `csrf_token`
  221. value in the default js-qweb execution context
  222. * if the form is accessed by an external third party (e.g. REST API
  223. endpoint, payment gateway callback) you will need to disable CSRF
  224. protection (and implement your own protection if necessary) by
  225. passing the `csrf=False` parameter to the `route` decorator.
  226. """
  227. # The @route arguments to propagate from the decorated method to the
  228. # routing rule.
  229. ROUTING_KEYS = {
  230. 'defaults', 'subdomain', 'build_only', 'strict_slashes', 'redirect_to',
  231. 'alias', 'host', 'methods',
  232. }
  233. if parse_version(werkzeug.__version__) >= parse_version('2.0.2'):
  234. # Werkzeug 2.0.2 adds the websocket option. If a websocket request
  235. # (ws/wss) is trying to access an HTTP route, a WebsocketMismatch
  236. # exception is raised. On the other hand, Werkzeug 0.16 does not
  237. # support the websocket routing key. In order to bypass this issue,
  238. # let's add the websocket key only when appropriate.
  239. ROUTING_KEYS.add('websocket')
  240. # The default duration of a user session cookie. Inactive sessions are reaped
  241. # server-side as well with a threshold that can be set via an optional
  242. # config parameter `sessions.max_inactivity_seconds` (default: SESSION_LIFETIME)
  243. SESSION_LIFETIME = 60 * 60 * 24 * 7
  244. # The cache duration for static content from the filesystem, one week.
  245. STATIC_CACHE = 60 * 60 * 24 * 7
  246. # The cache duration for content where the url uniquely identifies the
  247. # content (usually using a hash), one year.
  248. STATIC_CACHE_LONG = 60 * 60 * 24 * 365
  249. # =========================================================
  250. # Helpers
  251. # =========================================================
  252. class RegistryError(RuntimeError):
  253. pass
  254. class SessionExpiredException(Exception):
  255. pass
  256. def content_disposition(filename):
  257. return "attachment; filename*=UTF-8''{}".format(
  258. url_quote(filename, safe='', unsafe='()<>@,;:"/[]?={}\\*\'%') # RFC6266
  259. )
  260. def db_list(force=False, host=None):
  261. """
  262. Get the list of available databases.
  263. :param bool force: See :func:`~odoo.service.db.list_dbs`:
  264. :param host: The Host used to replace %h and %d in the dbfilters
  265. regexp. Taken from the current request when omitted.
  266. :returns: the list of available databases
  267. :rtype: List[str]
  268. """
  269. try:
  270. dbs = odoo.service.db.list_dbs(force)
  271. except psycopg2.OperationalError:
  272. return []
  273. return db_filter(dbs, host)
  274. def db_filter(dbs, host=None):
  275. """
  276. Return the subset of ``dbs`` that match the dbfilter or the dbname
  277. server configuration. In case neither are configured, return ``dbs``
  278. as-is.
  279. :param Iterable[str] dbs: The list of database names to filter.
  280. :param host: The Host used to replace %h and %d in the dbfilters
  281. regexp. Taken from the current request when omitted.
  282. :returns: The original list filtered.
  283. :rtype: List[str]
  284. """
  285. if config['dbfilter']:
  286. # host
  287. # -----------
  288. # www.example.com:80
  289. # -------
  290. # domain
  291. if host is None:
  292. host = request.httprequest.environ.get('HTTP_HOST', '')
  293. host = host.partition(':')[0]
  294. if host.startswith('www.'):
  295. host = host[4:]
  296. domain = host.partition('.')[0]
  297. dbfilter_re = re.compile(
  298. config["dbfilter"].replace("%h", re.escape(host))
  299. .replace("%d", re.escape(domain)))
  300. return [db for db in dbs if dbfilter_re.match(db)]
  301. if config['db_name']:
  302. # In case --db-filter is not provided and --database is passed, Odoo will
  303. # use the value of --database as a comma separated list of exposed databases.
  304. exposed_dbs = {db.strip() for db in config['db_name'].split(',')}
  305. return sorted(exposed_dbs.intersection(dbs))
  306. return list(dbs)
  307. def dispatch_rpc(service_name, method, params):
  308. """
  309. Perform a RPC call.
  310. :param str service_name: either "common", "db" or "object".
  311. :param str method: the method name of the given service to execute
  312. :param Mapping params: the keyword arguments for method call
  313. :return: the return value of the called method
  314. :rtype: Any
  315. """
  316. rpc_dispatchers = {
  317. 'common': odoo.service.common.dispatch,
  318. 'db': odoo.service.db.dispatch,
  319. 'object': odoo.service.model.dispatch,
  320. }
  321. with borrow_request():
  322. threading.current_thread().uid = None
  323. threading.current_thread().dbname = None
  324. dispatch = rpc_dispatchers[service_name]
  325. return dispatch(method, params)
  326. def get_session_max_inactivity(env):
  327. if not env or env.cr._closed:
  328. return SESSION_LIFETIME
  329. ICP = env['ir.config_parameter'].sudo()
  330. try:
  331. return int(ICP.get_param('sessions.max_inactivity_seconds', SESSION_LIFETIME))
  332. except ValueError:
  333. _logger.warning("Invalid value for 'sessions.max_inactivity_seconds', using default value.")
  334. return SESSION_LIFETIME
  335. def is_cors_preflight(request, endpoint):
  336. return request.httprequest.method == 'OPTIONS' and endpoint.routing.get('cors', False)
  337. def serialize_exception(exception):
  338. name = type(exception).__name__
  339. module = type(exception).__module__
  340. return {
  341. 'name': f'{module}.{name}' if module else name,
  342. 'debug': traceback.format_exc(),
  343. 'message': exception_to_unicode(exception),
  344. 'arguments': exception.args,
  345. 'context': getattr(exception, 'context', {}),
  346. }
  347. # =========================================================
  348. # File Streaming
  349. # =========================================================
  350. class Stream:
  351. """
  352. Send the content of a file, an attachment or a binary field via HTTP
  353. This utility is safe, cache-aware and uses the best available
  354. streaming strategy. Works best with the --x-sendfile cli option.
  355. Create a Stream via one of the constructors: :meth:`~from_path`:, or
  356. :meth:`~from_binary_field`:, generate the corresponding HTTP response
  357. object via :meth:`~get_response`:.
  358. Instantiating a Stream object manually without using one of the
  359. dedicated constructors is discouraged.
  360. """
  361. type: str = '' # 'data' or 'path' or 'url'
  362. data = None
  363. path = None
  364. url = None
  365. mimetype = None
  366. as_attachment = False
  367. download_name = None
  368. conditional = True
  369. etag = True
  370. last_modified = None
  371. max_age = None
  372. immutable = False
  373. size = None
  374. public = False
  375. def __init__(self, **kwargs):
  376. # Remove class methods from the instances
  377. self.from_path = self.from_attachment = self.from_binary_field = None
  378. self.__dict__.update(kwargs)
  379. @classmethod
  380. def from_path(cls, path, filter_ext=('',), public=False):
  381. """
  382. Create a :class:`~Stream`: from an addon resource.
  383. :param path: See :func:`~odoo.tools.file_path`
  384. :param filter_ext: See :func:`~odoo.tools.file_path`
  385. :param bool public: Advertise the resource as being cachable by
  386. intermediate proxies, otherwise only let the browser caches
  387. it.
  388. """
  389. path = file_path(path, filter_ext)
  390. check = adler32(path.encode())
  391. stat = os.stat(path)
  392. return cls(
  393. type='path',
  394. path=path,
  395. mimetype=mimetypes.guess_type(path)[0],
  396. download_name=os.path.basename(path),
  397. etag=f'{int(stat.st_mtime)}-{stat.st_size}-{check}',
  398. last_modified=stat.st_mtime,
  399. size=stat.st_size,
  400. public=public,
  401. )
  402. @classmethod
  403. def from_binary_field(cls, record, field_name):
  404. """ Create a :class:`~Stream`: from a binary field. """
  405. data_b64 = record[field_name]
  406. data = base64.b64decode(data_b64) if data_b64 else b''
  407. return cls(
  408. type='data',
  409. data=data,
  410. etag=request.env['ir.attachment']._compute_checksum(data),
  411. last_modified=record.write_date if record._log_access else None,
  412. size=len(data),
  413. public=record.env.user._is_public() # good enough
  414. )
  415. def read(self):
  416. """ Get the stream content as bytes. """
  417. if self.type == 'url':
  418. raise ValueError("Cannot read an URL")
  419. if self.type == 'data':
  420. return self.data
  421. with open(self.path, 'rb') as file:
  422. return file.read()
  423. def get_response(
  424. self,
  425. as_attachment=None,
  426. immutable=None,
  427. content_security_policy="default-src 'none'",
  428. **send_file_kwargs
  429. ):
  430. """
  431. Create the corresponding :class:`~Response` for the current stream.
  432. :param bool|None as_attachment: Indicate to the browser that it
  433. should offer to save the file instead of displaying it.
  434. :param bool|None immutable: Add the ``immutable`` directive to
  435. the ``Cache-Control`` response header, allowing intermediary
  436. proxies to aggressively cache the response. This option also
  437. set the ``max-age`` directive to 1 year.
  438. :param str|None content_security_policy: Optional value for the
  439. ``Content-Security-Policy`` (CSP) header. This header is
  440. used by browsers to allow/restrict the downloaded resource
  441. to itself perform new http requests. By default CSP is set
  442. to ``"default-scr 'none'"`` which restrict all requests.
  443. :param send_file_kwargs: Other keyword arguments to send to
  444. :func:`odoo.tools._vendor.send_file.send_file` instead of
  445. the stream sensitive values. Discouraged.
  446. """
  447. assert self.type in ('url', 'data', 'path'), "Invalid type: {self.type!r}, should be 'url', 'data' or 'path'."
  448. assert getattr(self, self.type) is not None, "There is nothing to stream, missing {self.type!r} attribute."
  449. if self.type == 'url':
  450. if self.max_age is not None:
  451. res = request.redirect(self.url, code=302, local=False)
  452. res.headers['Cache-Control'] = f'max-age={self.max_age}'
  453. return res
  454. return request.redirect(self.url, code=301, local=False)
  455. if as_attachment is None:
  456. as_attachment = self.as_attachment
  457. if immutable is None:
  458. immutable = self.immutable
  459. send_file_kwargs = {
  460. 'mimetype': self.mimetype,
  461. 'as_attachment': as_attachment,
  462. 'download_name': self.download_name,
  463. 'conditional': self.conditional,
  464. 'etag': self.etag,
  465. 'last_modified': self.last_modified,
  466. 'max_age': STATIC_CACHE_LONG if immutable else self.max_age,
  467. 'environ': request.httprequest.environ,
  468. 'response_class': Response,
  469. **send_file_kwargs,
  470. }
  471. if self.type == 'data':
  472. res = _send_file(BytesIO(self.data), **send_file_kwargs)
  473. else: # self.type == 'path'
  474. send_file_kwargs['use_x_sendfile'] = False
  475. if config['x_sendfile']:
  476. with contextlib.suppress(ValueError): # outside of the filestore
  477. fspath = Path(self.path).relative_to(opj(config['data_dir'], 'filestore'))
  478. x_accel_redirect = f'/web/filestore/{fspath}'
  479. send_file_kwargs['use_x_sendfile'] = True
  480. res = _send_file(self.path, **send_file_kwargs)
  481. if 'X-Sendfile' in res.headers:
  482. res.headers['X-Accel-Redirect'] = x_accel_redirect
  483. # In case of X-Sendfile/X-Accel-Redirect, the body is empty,
  484. # yet werkzeug gives the length of the file. This makes
  485. # NGINX wait for content that'll never arrive.
  486. res.headers['Content-Length'] = '0'
  487. res.headers['X-Content-Type-Options'] = 'nosniff'
  488. if content_security_policy: # see also Application.set_csp()
  489. res.headers['Content-Security-Policy'] = content_security_policy
  490. if self.public:
  491. if (res.cache_control.max_age or 0) > 0:
  492. res.cache_control.public = True
  493. else:
  494. res.cache_control.pop('public', '')
  495. res.cache_control.private = True
  496. if immutable:
  497. res.cache_control['immutable'] = None # None sets the directive
  498. return res
  499. # =========================================================
  500. # Controller and routes
  501. # =========================================================
  502. class Controller:
  503. """
  504. Class mixin that provide module controllers the ability to serve
  505. content over http and to be extended in child modules.
  506. Each class :ref:`inheriting <python:tut-inheritance>` from
  507. :class:`~odoo.http.Controller` can use the :func:`~odoo.http.route`:
  508. decorator to route matching incoming web requests to decorated
  509. methods.
  510. Like models, controllers can be extended by other modules. The
  511. extension mechanism is different because controllers can work in a
  512. database-free environment and therefore cannot use
  513. :class:~odoo.api.Registry:.
  514. To *override* a controller, :ref:`inherit <python:tut-inheritance>`
  515. from its class, override relevant methods and re-expose them with
  516. :func:`~odoo.http.route`:. Please note that the decorators of all
  517. methods are combined, if the overriding method’s decorator has no
  518. argument all previous ones will be kept, any provided argument will
  519. override previously defined ones.
  520. .. code-block:
  521. class GreetingController(odoo.http.Controller):
  522. @route('/greet', type='http', auth='public')
  523. def greeting(self):
  524. return 'Hello'
  525. class UserGreetingController(GreetingController):
  526. @route(auth='user') # override auth, keep path and type
  527. def greeting(self):
  528. return super().handler()
  529. """
  530. children_classes = collections.defaultdict(list) # indexed by module
  531. @classmethod
  532. def __init_subclass__(cls):
  533. super().__init_subclass__()
  534. if Controller in cls.__bases__:
  535. path = cls.__module__.split('.')
  536. module = path[2] if path[:2] == ['odoo', 'addons'] else ''
  537. Controller.children_classes[module].append(cls)
  538. def route(route=None, **routing):
  539. """
  540. Decorate a controller method in order to route incoming requests
  541. matching the given URL and options to the decorated method.
  542. .. warning::
  543. It is mandatory to re-decorate any method that is overridden in
  544. controller extensions but the arguments can be omitted. See
  545. :class:`~odoo.http.Controller` for more details.
  546. :param Union[str, Iterable[str]] route: The paths that the decorated
  547. method is serving. Incoming HTTP request paths matching this
  548. route will be routed to this decorated method. See `werkzeug
  549. routing documentation <http://werkzeug.pocoo.org/docs/routing/>`_
  550. for the format of route expressions.
  551. :param str type: The type of request, either ``'json'`` or
  552. ``'http'``. It describes where to find the request parameters
  553. and how to serialize the response.
  554. :param str auth: The authentication method, one of the following:
  555. * ``'user'``: The user must be authenticated and the current
  556. request will be executed using the rights of the user.
  557. * ``'bearer'``: The user is authenticated using an "Authorization"
  558. request header, using the Bearer scheme with an API token.
  559. The request will be executed with the permissions of the
  560. corresponding user. If the header is missing, the request
  561. must belong to an authentication session, as for the "user"
  562. authentication method.
  563. * ``'public'``: The user may or may not be authenticated. If he
  564. isn't, the current request will be executed using the shared
  565. Public user.
  566. * ``'none'``: The method is always active, even if there is no
  567. database. Mainly used by the framework and authentication
  568. modules. The request code will not have any facilities to
  569. access the current user.
  570. :param Iterable[str] methods: A list of http methods (verbs) this
  571. route applies to. If not specified, all methods are allowed.
  572. :param str cors: The Access-Control-Allow-Origin cors directive value.
  573. :param bool csrf: Whether CSRF protection should be enabled for the
  574. route. Enabled by default for ``'http'``-type requests, disabled
  575. by default for ``'json'``-type requests.
  576. :param Union[bool, Callable[[registry, request], bool]] readonly:
  577. Whether this endpoint should open a cursor on a read-only
  578. replica instead of (by default) the primary read/write database.
  579. :param Callable[[Exception], Response] handle_params_access_error:
  580. Implement a custom behavior if an error occurred when retrieving the record
  581. from the URL parameters (access error or missing error).
  582. """
  583. def decorator(endpoint):
  584. fname = f"<function {endpoint.__module__}.{endpoint.__name__}>"
  585. # Sanitize the routing
  586. assert routing.get('type', 'http') in _dispatchers.keys()
  587. if route:
  588. routing['routes'] = [route] if isinstance(route, str) else route
  589. wrong = routing.pop('method', None)
  590. if wrong is not None:
  591. _logger.warning("%s defined with invalid routing parameter 'method', assuming 'methods'", fname)
  592. routing['methods'] = wrong
  593. @functools.wraps(endpoint)
  594. def route_wrapper(self, *args, **params):
  595. params_ok = filter_kwargs(endpoint, params)
  596. params_ko = set(params) - set(params_ok)
  597. if params_ko:
  598. _logger.warning("%s called ignoring args %s", fname, params_ko)
  599. result = endpoint(self, *args, **params_ok)
  600. if routing['type'] == 'http': # _generate_routing_rules() ensures type is set
  601. return Response.load(result)
  602. return result
  603. route_wrapper.original_routing = routing
  604. route_wrapper.original_endpoint = endpoint
  605. return route_wrapper
  606. return decorator
  607. def _generate_routing_rules(modules, nodb_only, converters=None):
  608. """
  609. Two-fold algorithm used to (1) determine which method in the
  610. controller inheritance tree should bind to what URL with respect to
  611. the list of installed modules and (2) merge the various @route
  612. arguments of said method with the @route arguments of the method it
  613. overrides.
  614. """
  615. def is_valid(cls):
  616. """ Determine if the class is defined in an addon. """
  617. path = cls.__module__.split('.')
  618. return path[:2] == ['odoo', 'addons'] and path[2] in modules
  619. def get_leaf_classes(cls):
  620. """
  621. Find the classes that have no child and that have ``cls`` as
  622. ancestor.
  623. """
  624. result = []
  625. for subcls in cls.__subclasses__():
  626. if is_valid(subcls):
  627. result.extend(get_leaf_classes(subcls))
  628. if not result and is_valid(cls):
  629. result.append(cls)
  630. return result
  631. def build_controllers():
  632. """
  633. Create dummy controllers that inherit only from the controllers
  634. defined at the given ``modules`` (often system wide modules or
  635. installed modules). Modules in this context are Odoo addons.
  636. """
  637. # Controllers defined outside of odoo addons are outside of the
  638. # controller inheritance/extension mechanism.
  639. yield from (ctrl() for ctrl in Controller.children_classes.get('', []))
  640. # Controllers defined inside of odoo addons can be extended in
  641. # other installed addons. Rebuild the class inheritance here.
  642. highest_controllers = []
  643. for module in modules:
  644. highest_controllers.extend(Controller.children_classes.get(module, []))
  645. for top_ctrl in highest_controllers:
  646. leaf_controllers = list(unique(get_leaf_classes(top_ctrl)))
  647. name = top_ctrl.__name__
  648. if leaf_controllers != [top_ctrl]:
  649. name += ' (extended by %s)' % ', '.join(
  650. bot_ctrl.__name__
  651. for bot_ctrl in leaf_controllers
  652. if bot_ctrl is not top_ctrl
  653. )
  654. Ctrl = type(name, tuple(reversed(leaf_controllers)), {})
  655. yield Ctrl()
  656. for ctrl in build_controllers():
  657. for method_name, method in inspect.getmembers(ctrl, inspect.ismethod):
  658. # Skip this method if it is not @route decorated anywhere in
  659. # the hierarchy
  660. def is_method_a_route(cls):
  661. return getattr(getattr(cls, method_name, None), 'original_routing', None) is not None
  662. if not any(map(is_method_a_route, type(ctrl).mro())):
  663. continue
  664. merged_routing = {
  665. # 'type': 'http', # set below
  666. 'auth': 'user',
  667. 'methods': None,
  668. 'routes': [],
  669. }
  670. for cls in unique(reversed(type(ctrl).mro()[:-2])): # ancestors first
  671. if method_name not in cls.__dict__:
  672. continue
  673. submethod = getattr(cls, method_name)
  674. if not hasattr(submethod, 'original_routing'):
  675. _logger.warning("The endpoint %s is not decorated by @route(), decorating it myself.", f'{cls.__module__}.{cls.__name__}.{method_name}')
  676. submethod = route()(submethod)
  677. _check_and_complete_route_definition(cls, submethod, merged_routing)
  678. merged_routing.update(submethod.original_routing)
  679. if not merged_routing['routes']:
  680. _logger.warning("%s is a controller endpoint without any route, skipping.", f'{cls.__module__}.{cls.__name__}.{method_name}')
  681. continue
  682. if nodb_only and merged_routing['auth'] != "none":
  683. continue
  684. for url in merged_routing['routes']:
  685. # duplicates the function (partial) with a copy of the
  686. # original __dict__ (update_wrapper) to keep a reference
  687. # to `original_routing` and `original_endpoint`, assign
  688. # the merged routing ONLY on the duplicated function to
  689. # ensure method's immutability.
  690. endpoint = functools.partial(method)
  691. functools.update_wrapper(endpoint, method)
  692. endpoint.routing = merged_routing
  693. yield (url, endpoint)
  694. def _check_and_complete_route_definition(controller_cls, submethod, merged_routing):
  695. """Verify and complete the route definition.
  696. * Ensure 'type' is defined on each method's own routing.
  697. * Ensure overrides don't change the routing type or the read/write mode
  698. :param submethod: route method
  699. :param dict merged_routing: accumulated routing values
  700. """
  701. default_type = submethod.original_routing.get('type', 'http')
  702. routing_type = merged_routing.setdefault('type', default_type)
  703. if submethod.original_routing.get('type') not in (None, routing_type):
  704. _logger.warning(
  705. "The endpoint %s changes the route type, using the original type: %r.",
  706. f'{controller_cls.__module__}.{controller_cls.__name__}.{submethod.__name__}',
  707. routing_type)
  708. submethod.original_routing['type'] = routing_type
  709. default_auth = submethod.original_routing.get('auth', merged_routing['auth'])
  710. default_mode = submethod.original_routing.get('readonly', default_auth == 'none')
  711. parent_readonly = merged_routing.setdefault('readonly', default_mode)
  712. child_readonly = submethod.original_routing.get('readonly')
  713. if child_readonly not in (None, parent_readonly) and not callable(child_readonly):
  714. _logger.warning(
  715. "The endpoint %s made the route %s altough its parent was defined as %s. Setting the route read/write.",
  716. f'{controller_cls.__module__}.{controller_cls.__name__}.{submethod.__name__}',
  717. 'readonly' if child_readonly else 'read/write',
  718. 'readonly' if parent_readonly else 'read/write',
  719. )
  720. submethod.original_routing['readonly'] = False
  721. # =========================================================
  722. # Session
  723. # =========================================================
  724. _base64_urlsafe_re = re.compile(r'^[A-Za-z0-9_-]{84}$')
  725. class FilesystemSessionStore(sessions.FilesystemSessionStore):
  726. """ Place where to load and save session objects. """
  727. def get_session_filename(self, sid):
  728. # scatter sessions across 4096 (64^2) directories
  729. if not self.is_valid_key(sid):
  730. raise ValueError(f'Invalid session id {sid!r}')
  731. sha_dir = sid[:2]
  732. dirname = os.path.join(self.path, sha_dir)
  733. session_path = os.path.join(dirname, sid)
  734. return session_path
  735. def save(self, session):
  736. session_path = self.get_session_filename(session.sid)
  737. dirname = os.path.dirname(session_path)
  738. if not os.path.isdir(dirname):
  739. with contextlib.suppress(OSError):
  740. os.mkdir(dirname, 0o0755)
  741. super().save(session)
  742. def get(self, sid):
  743. # retro compatibility
  744. old_path = super().get_session_filename(sid)
  745. session_path = self.get_session_filename(sid)
  746. if os.path.isfile(old_path) and not os.path.isfile(session_path):
  747. dirname = os.path.dirname(session_path)
  748. if not os.path.isdir(dirname):
  749. with contextlib.suppress(OSError):
  750. os.mkdir(dirname, 0o0755)
  751. with contextlib.suppress(OSError):
  752. os.rename(old_path, session_path)
  753. return super().get(sid)
  754. def rotate(self, session, env):
  755. self.delete(session)
  756. session.sid = self.generate_key()
  757. if session.uid and env:
  758. session.session_token = security.compute_session_token(session, env)
  759. session.should_rotate = False
  760. self.save(session)
  761. def vacuum(self, max_lifetime=SESSION_LIFETIME):
  762. threshold = time.time() - max_lifetime
  763. for fname in glob.iglob(os.path.join(root.session_store.path, '*', '*')):
  764. path = os.path.join(root.session_store.path, fname)
  765. with contextlib.suppress(OSError):
  766. if os.path.getmtime(path) < threshold:
  767. os.unlink(path)
  768. def generate_key(self, salt=None):
  769. # The generated key is case sensitive (base64) and the length is 84 chars.
  770. # In the worst-case scenario, i.e. in an insensitive filesystem (NTFS for example)
  771. # taking into account the proportion of characters in the pool and a length
  772. # of 42 (stored part in the database), the entropy for the base64 generated key
  773. # is 217.875 bits which is better than the 160 bits entropy of a hexadecimal key
  774. # with a length of 40 (method ``generate_key`` of ``SessionStore``).
  775. # The risk of collision is negligible in practice.
  776. # Formulas:
  777. # - L: length of generated word
  778. # - p_char: probability of obtaining the character in the pool
  779. # - n: size of the pool
  780. # - k: number of generated word
  781. # Entropy = - L * sum(p_char * log2(p_char))
  782. # Collision ~= (1 - exp((-k * (k - 1)) / (2 * (n**L))))
  783. key = str(time.time()).encode() + os.urandom(64)
  784. hash_key = sha512(key).digest()[:-1] # prevent base64 padding
  785. return base64.urlsafe_b64encode(hash_key).decode('utf-8')
  786. def is_valid_key(self, key):
  787. return _base64_urlsafe_re.match(key) is not None
  788. def delete_from_identifiers(self, identifiers):
  789. files_to_unlink = []
  790. for identifier in identifiers:
  791. # Avoid to remove a session if less than 42 chars.
  792. # This prevent malicious user to delete sessions from a different
  793. # database by specifying a ``res.device.log`` with only 2 characters.
  794. if len(identifier) < 42:
  795. continue
  796. normalized_path = os.path.normpath(os.path.join(self.path, identifier[:2], identifier + '*'))
  797. if normalized_path.startswith(self.path):
  798. files_to_unlink.extend(glob.glob(normalized_path))
  799. for fn in files_to_unlink:
  800. with contextlib.suppress(OSError):
  801. os.unlink(fn)
  802. class Session(collections.abc.MutableMapping):
  803. """ Structure containing data persisted across requests. """
  804. __slots__ = ('can_save', '_Session__data', 'is_dirty', 'is_new',
  805. 'should_rotate', 'sid')
  806. def __init__(self, data, sid, new=False):
  807. self.can_save = True
  808. self.__data = {}
  809. self.update(data)
  810. self.is_dirty = False
  811. self.is_new = new
  812. self.should_rotate = False
  813. self.sid = sid
  814. #
  815. # MutableMapping implementation with DocDict-like extension
  816. #
  817. def __getitem__(self, item):
  818. return self.__data[item]
  819. def __setitem__(self, item, value):
  820. value = json.loads(json.dumps(value))
  821. if item not in self.__data or self.__data[item] != value:
  822. self.is_dirty = True
  823. self.__data[item] = value
  824. def __delitem__(self, item):
  825. del self.__data[item]
  826. self.is_dirty = True
  827. def __len__(self):
  828. return len(self.__data)
  829. def __iter__(self):
  830. return iter(self.__data)
  831. def __getattr__(self, attr):
  832. return self.get(attr, None)
  833. def __setattr__(self, key, val):
  834. if key in self.__slots__:
  835. super().__setattr__(key, val)
  836. else:
  837. self[key] = val
  838. def clear(self):
  839. self.__data.clear()
  840. self.is_dirty = True
  841. #
  842. # Session methods
  843. #
  844. def authenticate(self, dbname, credential):
  845. """
  846. Authenticate the current user with the given db, login and
  847. credential. If successful, store the authentication parameters in
  848. the current session, unless multi-factor-auth (MFA) is
  849. activated. In that case, that last part will be done by
  850. :ref:`finalize`.
  851. .. versionchanged:: saas-15.3
  852. The current request is no longer updated using the user and
  853. context of the session when the authentication is done using
  854. a database different than request.db. It is up to the caller
  855. to open a new cursor/registry/env on the given database.
  856. """
  857. wsgienv = {
  858. 'interactive': True,
  859. 'base_location': request.httprequest.url_root.rstrip('/'),
  860. 'HTTP_HOST': request.httprequest.environ['HTTP_HOST'],
  861. 'REMOTE_ADDR': request.httprequest.environ['REMOTE_ADDR'],
  862. }
  863. registry = Registry(dbname)
  864. auth_info = registry['res.users'].authenticate(dbname, credential, wsgienv)
  865. pre_uid = auth_info['uid']
  866. self.uid = None
  867. self.pre_login = credential['login']
  868. self.pre_uid = pre_uid
  869. with registry.cursor() as cr:
  870. env = odoo.api.Environment(cr, pre_uid, {})
  871. # if 2FA is disabled we finalize immediately
  872. user = env['res.users'].browse(pre_uid)
  873. if auth_info.get('mfa') == 'skip' or not user._mfa_url():
  874. self.finalize(env)
  875. if request and request.session is self and request.db == dbname:
  876. request.env = odoo.api.Environment(request.env.cr, self.uid, self.context)
  877. request.update_context(lang=get_lang(request.env(user=pre_uid)).code)
  878. # request env needs to be able to access the latest changes from the auth layers
  879. request.env.cr.commit()
  880. return auth_info
  881. def finalize(self, env):
  882. """
  883. Finalizes a partial session, should be called on MFA validation
  884. to convert a partial / pre-session into a logged-in one.
  885. """
  886. login = self.pop('pre_login')
  887. uid = self.pop('pre_uid')
  888. env = env(user=uid)
  889. user_context = dict(env['res.users'].context_get())
  890. self.should_rotate = True
  891. self.update({
  892. 'db': env.registry.db_name,
  893. 'login': login,
  894. 'uid': uid,
  895. 'context': user_context,
  896. 'session_token': env.user._compute_session_token(self.sid),
  897. })
  898. def logout(self, keep_db=False):
  899. db = self.db if keep_db else get_default_session()['db'] # None
  900. debug = self.debug
  901. self.clear()
  902. self.update(get_default_session(), db=db, debug=debug)
  903. self.context['lang'] = request.default_lang() if request else DEFAULT_LANG
  904. self.should_rotate = True
  905. if request and request.env:
  906. request.env['ir.http']._post_logout()
  907. def touch(self):
  908. self.is_dirty = True
  909. def update_trace(self, request):
  910. """
  911. :return: dict if a device log has to be inserted, ``None`` otherwise
  912. """
  913. if self._trace_disable:
  914. # To avoid generating useless logs, e.g. for automated technical sessions,
  915. # a session can be flagged with `_trace_disable`. This should never be done
  916. # without a proper assessment of the consequences for auditability.
  917. # Non-admin users have no direct or indirect way to set this flag, so it can't
  918. # be abused by unprivileged users. Such sessions will of course still be
  919. # subject to all other auditing mechanisms (server logs, web proxy logs,
  920. # metadata tracking on modified records, etc.)
  921. return
  922. user_agent = request.httprequest.user_agent
  923. platform = user_agent.platform
  924. browser = user_agent.browser
  925. ip_address = request.httprequest.remote_addr
  926. now = int(datetime.now().timestamp())
  927. for trace in self._trace:
  928. if trace['platform'] == platform and trace['browser'] == browser and trace['ip_address'] == ip_address:
  929. # If the device logs are not up to date (i.e. not updated for one hour or more)
  930. if bool(now - trace['last_activity'] >= 3600):
  931. trace['last_activity'] = now
  932. self.is_dirty = True
  933. return trace
  934. return
  935. new_trace = {
  936. 'platform': platform,
  937. 'browser': browser,
  938. 'ip_address': ip_address,
  939. 'first_activity': now,
  940. 'last_activity': now
  941. }
  942. self._trace.append(new_trace)
  943. self.is_dirty = True
  944. return new_trace
  945. # =========================================================
  946. # GeoIP
  947. # =========================================================
  948. class GeoIP(collections.abc.Mapping):
  949. """
  950. Ip Geolocalization utility, determine information such as the
  951. country or the timezone of the user based on their IP Address.
  952. The instances share the same API as `:class:`geoip2.models.City`
  953. <https://geoip2.readthedocs.io/en/latest/#geoip2.models.City>`_.
  954. When the IP couldn't be geolocalized (missing database, bad address)
  955. then an empty object is returned. This empty object can be used like
  956. a regular one with the exception that all info are set None.
  957. :param str ip: The IP Address to geo-localize
  958. .. note:
  959. The geoip info the the current request are available at
  960. :attr:`~odoo.http.request.geoip`.
  961. .. code-block:
  962. >>> GeoIP('127.0.0.1').country.iso_code
  963. >>> odoo_ip = socket.gethostbyname('odoo.com')
  964. >>> GeoIP(odoo_ip).country.iso_code
  965. 'FR'
  966. """
  967. def __init__(self, ip):
  968. self.ip = ip
  969. @lazy_property
  970. def _city_record(self):
  971. try:
  972. return root.geoip_city_db.city(self.ip)
  973. except (OSError, maxminddb.InvalidDatabaseError):
  974. return GEOIP_EMPTY_CITY
  975. except geoip2.errors.AddressNotFoundError:
  976. return GEOIP_EMPTY_CITY
  977. @lazy_property
  978. def _country_record(self):
  979. if '_city_record' in vars(self):
  980. # the City class inherits from the Country class and the
  981. # city record is in cache already, save a geolocalization
  982. return self._city_record
  983. try:
  984. return root.geoip_country_db.country(self.ip)
  985. except (OSError, maxminddb.InvalidDatabaseError):
  986. return self._city_record
  987. except geoip2.errors.AddressNotFoundError:
  988. return GEOIP_EMPTY_COUNTRY
  989. @property
  990. def country_name(self):
  991. return self.country.name or self.continent.name
  992. @property
  993. def country_code(self):
  994. return self.country.iso_code or self.continent.code
  995. def __getattr__(self, attr):
  996. # Be smart and determine whether the attribute exists on the
  997. # country object or on the city object.
  998. if hasattr(GEOIP_EMPTY_COUNTRY, attr):
  999. return getattr(self._country_record, attr)
  1000. if hasattr(GEOIP_EMPTY_CITY, attr):
  1001. return getattr(self._city_record, attr)
  1002. raise AttributeError(f"{self} has no attribute {attr!r}")
  1003. def __bool__(self):
  1004. return self.country_name is not None
  1005. # Old dict API, undocumented for now, will be deprecated some day
  1006. def __getitem__(self, item):
  1007. if item == 'country_name':
  1008. return self.country_name
  1009. if item == 'country_code':
  1010. return self.country_code
  1011. if item == 'city':
  1012. return self.city.name
  1013. if item == 'latitude':
  1014. return self.location.latitude
  1015. if item == 'longitude':
  1016. return self.location.longitude
  1017. if item == 'region':
  1018. return self.subdivisions[0].iso_code if self.subdivisions else None
  1019. if item == 'time_zone':
  1020. return self.location.time_zone
  1021. raise KeyError(item)
  1022. def __iter__(self):
  1023. raise NotImplementedError("The dictionnary GeoIP API is deprecated.")
  1024. def __len__(self):
  1025. raise NotImplementedError("The dictionnary GeoIP API is deprecated.")
  1026. # =========================================================
  1027. # Request and Response
  1028. # =========================================================
  1029. # Thread local global request object
  1030. _request_stack = werkzeug.local.LocalStack()
  1031. request = _request_stack()
  1032. @contextlib.contextmanager
  1033. def borrow_request():
  1034. """ Get the current request and unexpose it from the local stack. """
  1035. req = _request_stack.pop()
  1036. try:
  1037. yield req
  1038. finally:
  1039. _request_stack.push(req)
  1040. def make_request_wrap_methods(attr):
  1041. def getter(self):
  1042. return getattr(self._HTTPRequest__wrapped, attr)
  1043. def setter(self, value):
  1044. return setattr(self._HTTPRequest__wrapped, attr, value)
  1045. return getter, setter
  1046. class HTTPRequest:
  1047. def __init__(self, environ):
  1048. httprequest = werkzeug.wrappers.Request(environ)
  1049. httprequest.user_agent_class = UserAgent # use vendored userAgent since it will be removed in 2.1
  1050. httprequest.parameter_storage_class = werkzeug.datastructures.ImmutableOrderedMultiDict
  1051. httprequest.max_content_length = DEFAULT_MAX_CONTENT_LENGTH
  1052. httprequest.max_form_memory_size = 10 * 1024 * 1024 # 10 MB
  1053. self.__wrapped = httprequest
  1054. self.__environ = self.__wrapped.environ
  1055. self.environ = self.headers.environ = {
  1056. key: value
  1057. for key, value in self.__environ.items()
  1058. if (not key.startswith(('werkzeug.', 'wsgi.', 'socket')) or key in ['wsgi.url_scheme', 'werkzeug.proxy_fix.orig'])
  1059. }
  1060. def __enter__(self):
  1061. return self
  1062. HTTPREQUEST_ATTRIBUTES = [
  1063. '__str__', '__repr__', '__exit__',
  1064. 'accept_charsets', 'accept_languages', 'accept_mimetypes', 'access_route', 'args', 'authorization', 'base_url',
  1065. 'charset', 'content_encoding', 'content_length', 'content_md5', 'content_type', 'cookies', 'data', 'date',
  1066. 'encoding_errors', 'files', 'form', 'full_path', 'get_data', 'get_json', 'headers', 'host', 'host_url', 'if_match',
  1067. 'if_modified_since', 'if_none_match', 'if_range', 'if_unmodified_since', 'is_json', 'is_secure', 'json',
  1068. 'max_content_length', 'method', 'mimetype', 'mimetype_params', 'origin', 'path', 'pragma', 'query_string', 'range',
  1069. 'referrer', 'remote_addr', 'remote_user', 'root_path', 'root_url', 'scheme', 'script_root', 'server', 'session',
  1070. 'trusted_hosts', 'url', 'url_charset', 'url_root', 'user_agent', 'values',
  1071. ]
  1072. for attr in HTTPREQUEST_ATTRIBUTES:
  1073. setattr(HTTPRequest, attr, property(*make_request_wrap_methods(attr)))
  1074. class Response(werkzeug.wrappers.Response):
  1075. """
  1076. Outgoing HTTP response with body, status, headers and qweb support.
  1077. In addition to the :class:`werkzeug.wrappers.Response` parameters,
  1078. this class's constructor can take the following additional
  1079. parameters for QWeb Lazy Rendering.
  1080. :param str template: template to render
  1081. :param dict qcontext: Rendering context to use
  1082. :param int uid: User id to use for the ir.ui.view render call,
  1083. ``None`` to use the request's user (the default)
  1084. these attributes are available as parameters on the Response object
  1085. and can be altered at any time before rendering
  1086. Also exposes all the attributes and methods of
  1087. :class:`werkzeug.wrappers.Response`.
  1088. """
  1089. default_mimetype = 'text/html'
  1090. def __init__(self, *args, **kw):
  1091. template = kw.pop('template', None)
  1092. qcontext = kw.pop('qcontext', None)
  1093. uid = kw.pop('uid', None)
  1094. super().__init__(*args, **kw)
  1095. self.set_default(template, qcontext, uid)
  1096. @classmethod
  1097. def load(cls, result, fname="<function>"):
  1098. """
  1099. Convert the return value of an endpoint into a Response.
  1100. :param result: The endpoint return value to load the Response from.
  1101. :type result: Union[Response, werkzeug.wrappers.BaseResponse,
  1102. werkzeug.exceptions.HTTPException, str, bytes, NoneType]
  1103. :param str fname: The endpoint function name wherefrom the
  1104. result emanated, used for logging.
  1105. :returns: The created :class:`~odoo.http.Response`.
  1106. :rtype: Response
  1107. :raises TypeError: When ``result`` type is none of the above-
  1108. mentioned type.
  1109. """
  1110. if isinstance(result, Response):
  1111. return result
  1112. if isinstance(result, werkzeug.exceptions.HTTPException):
  1113. _logger.warning("%s returns an HTTPException instead of raising it.", fname)
  1114. raise result
  1115. if isinstance(result, werkzeug.wrappers.Response):
  1116. response = cls.force_type(result)
  1117. response.set_default()
  1118. return response
  1119. if isinstance(result, (bytes, str, type(None))):
  1120. return cls(result)
  1121. raise TypeError(f"{fname} returns an invalid value: {result}")
  1122. def set_default(self, template=None, qcontext=None, uid=None):
  1123. self.template = template
  1124. self.qcontext = qcontext or dict()
  1125. self.qcontext['response_template'] = self.template
  1126. self.uid = uid
  1127. @property
  1128. def is_qweb(self):
  1129. return self.template is not None
  1130. def render(self):
  1131. """ Renders the Response's template, returns the result. """
  1132. self.qcontext['request'] = request
  1133. return request.env["ir.ui.view"]._render_template(self.template, self.qcontext)
  1134. def flatten(self):
  1135. """
  1136. Forces the rendering of the response's template, sets the result
  1137. as response body and unsets :attr:`.template`
  1138. """
  1139. if self.template:
  1140. self.response.append(self.render())
  1141. self.template = None
  1142. def set_cookie(self, key, value='', max_age=None, expires=-1, path='/', domain=None, secure=False, httponly=False, samesite=None, cookie_type='required'):
  1143. """
  1144. The default expires in Werkzeug is None, which means a session cookie.
  1145. We want to continue to support the session cookie, but not by default.
  1146. Now the default is arbitrary 1 year.
  1147. So if you want a cookie of session, you have to explicitly pass expires=None.
  1148. """
  1149. if expires == -1: # not provided value -> default value -> 1 year
  1150. expires = datetime.now() + timedelta(days=365)
  1151. if request.db and not request.env['ir.http']._is_allowed_cookie(cookie_type):
  1152. max_age = 0
  1153. super().set_cookie(key, value=value, max_age=max_age, expires=expires, path=path, domain=domain, secure=secure, httponly=httponly, samesite=samesite)
  1154. class FutureResponse:
  1155. """
  1156. werkzeug.Response mock class that only serves as placeholder for
  1157. headers to be injected in the final response.
  1158. """
  1159. # used by werkzeug.Response.set_cookie
  1160. charset = 'utf-8'
  1161. max_cookie_size = 4093
  1162. def __init__(self):
  1163. self.headers = werkzeug.datastructures.Headers()
  1164. @property
  1165. def _charset(self):
  1166. return self.charset
  1167. @functools.wraps(werkzeug.Response.set_cookie)
  1168. def set_cookie(self, key, value='', max_age=None, expires=-1, path='/', domain=None, secure=False, httponly=False, samesite=None, cookie_type='required'):
  1169. if expires == -1: # not forced value -> default value -> 1 year
  1170. expires = datetime.now() + timedelta(days=365)
  1171. if request.db and not request.env['ir.http']._is_allowed_cookie(cookie_type):
  1172. max_age = 0
  1173. werkzeug.Response.set_cookie(self, key, value=value, max_age=max_age, expires=expires, path=path, domain=domain, secure=secure, httponly=httponly, samesite=samesite)
  1174. class Request:
  1175. """
  1176. Wrapper around the incoming HTTP request with deserialized request
  1177. parameters, session utilities and request dispatching logic.
  1178. """
  1179. def __init__(self, httprequest):
  1180. self.httprequest = httprequest
  1181. self.future_response = FutureResponse()
  1182. self.dispatcher = _dispatchers['http'](self) # until we match
  1183. #self.params = {} # set by the Dispatcher
  1184. self.geoip = GeoIP(httprequest.remote_addr)
  1185. self.registry = None
  1186. self.env = None
  1187. def _post_init(self):
  1188. self.session, self.db = self._get_session_and_dbname()
  1189. self._post_init = None
  1190. def _get_session_and_dbname(self):
  1191. sid = self.httprequest.cookies.get('session_id')
  1192. if not sid or not root.session_store.is_valid_key(sid):
  1193. session = root.session_store.new()
  1194. else:
  1195. session = root.session_store.get(sid)
  1196. session.sid = sid # in case the session was not persisted
  1197. for key, val in get_default_session().items():
  1198. session.setdefault(key, val)
  1199. if not session.context.get('lang'):
  1200. session.context['lang'] = self.default_lang()
  1201. dbname = None
  1202. host = self.httprequest.environ['HTTP_HOST']
  1203. if session.db and db_filter([session.db], host=host):
  1204. dbname = session.db
  1205. else:
  1206. all_dbs = db_list(force=True, host=host)
  1207. if len(all_dbs) == 1:
  1208. dbname = all_dbs[0] # monodb
  1209. if session.db != dbname:
  1210. if session.db:
  1211. _logger.warning("Logged into database %r, but dbfilter rejects it; logging session out.", session.db)
  1212. session.logout(keep_db=False)
  1213. session.db = dbname
  1214. session.is_dirty = False
  1215. return session, dbname
  1216. def _open_registry(self):
  1217. try:
  1218. registry = Registry(self.db)
  1219. # use a RW cursor! Sequence data is not replicated and would
  1220. # be invalid if accessed on a readonly replica. Cfr task-4399456
  1221. cr_readwrite = registry.cursor(readonly=False)
  1222. registry = registry.check_signaling(cr_readwrite)
  1223. except (AttributeError, psycopg2.OperationalError, psycopg2.ProgrammingError) as e:
  1224. raise RegistryError(f"Cannot get registry {self.db}") from e
  1225. return registry, cr_readwrite
  1226. # =====================================================
  1227. # Getters and setters
  1228. # =====================================================
  1229. def update_env(self, user=None, context=None, su=None):
  1230. """ Update the environment of the current request.
  1231. :param user: optional user/user id to change the current user
  1232. :type user: int or :class:`res.users record<~odoo.addons.base.models.res_users.Users>`
  1233. :param dict context: optional context dictionary to change the current context
  1234. :param bool su: optional boolean to change the superuser mode
  1235. """
  1236. cr = None # None is a sentinel, it keeps the same cursor
  1237. self.env = self.env(cr, user, context, su)
  1238. threading.current_thread().uid = self.env.uid
  1239. def update_context(self, **overrides):
  1240. """
  1241. Override the environment context of the current request with the
  1242. values of ``overrides``. To replace the entire context, please
  1243. use :meth:`~update_env` instead.
  1244. """
  1245. self.update_env(context=dict(self.env.context, **overrides))
  1246. @property
  1247. def context(self):
  1248. return self.env.context
  1249. @context.setter
  1250. def context(self, value):
  1251. raise NotImplementedError("Use request.update_context instead.")
  1252. @property
  1253. def uid(self):
  1254. return self.env.uid
  1255. @uid.setter
  1256. def uid(self, value):
  1257. raise NotImplementedError("Use request.update_env instead.")
  1258. @property
  1259. def cr(self):
  1260. return self.env.cr
  1261. @cr.setter
  1262. def cr(self, value):
  1263. if value is None:
  1264. raise NotImplementedError("Close the cursor instead.")
  1265. raise ValueError("You cannot replace the cursor attached to the current request.")
  1266. _cr = cr
  1267. @lazy_property
  1268. def best_lang(self):
  1269. lang = self.httprequest.accept_languages.best
  1270. if not lang:
  1271. return None
  1272. try:
  1273. code, territory, _, _ = babel.core.parse_locale(lang, sep='-')
  1274. if territory:
  1275. lang = f'{code}_{territory}'
  1276. else:
  1277. lang = babel.core.LOCALE_ALIASES[code]
  1278. return lang
  1279. except (ValueError, KeyError):
  1280. return None
  1281. @lazy_property
  1282. def cookies(self):
  1283. cookies = werkzeug.datastructures.MultiDict(self.httprequest.cookies)
  1284. if self.registry:
  1285. self.registry['ir.http']._sanitize_cookies(cookies)
  1286. return werkzeug.datastructures.ImmutableMultiDict(cookies)
  1287. # =====================================================
  1288. # Helpers
  1289. # =====================================================
  1290. def csrf_token(self, time_limit=None):
  1291. """
  1292. Generates and returns a CSRF token for the current session
  1293. :param Optional[int] time_limit: the CSRF token should only be
  1294. valid for the specified duration (in second), by default
  1295. 48h, ``None`` for the token to be valid as long as the
  1296. current user's session is.
  1297. :returns: ASCII token string
  1298. :rtype: str
  1299. """
  1300. secret = self.env['ir.config_parameter'].sudo().get_param('database.secret')
  1301. if not secret:
  1302. raise ValueError("CSRF protection requires a configured database secret")
  1303. # if no `time_limit` => distant 1y expiry so max_ts acts as salt, e.g. vs BREACH
  1304. max_ts = int(time.time() + (time_limit or CSRF_TOKEN_SALT))
  1305. msg = f'{self.session.sid}{max_ts}'.encode('utf-8')
  1306. hm = hmac.new(secret.encode('ascii'), msg, hashlib.sha1).hexdigest()
  1307. return f'{hm}o{max_ts}'
  1308. def validate_csrf(self, csrf):
  1309. """
  1310. Is the given csrf token valid ?
  1311. :param str csrf: The token to validate.
  1312. :returns: ``True`` when valid, ``False`` when not.
  1313. :rtype: bool
  1314. """
  1315. if not csrf:
  1316. return False
  1317. secret = self.env['ir.config_parameter'].sudo().get_param('database.secret')
  1318. if not secret:
  1319. raise ValueError("CSRF protection requires a configured database secret")
  1320. hm, _, max_ts = csrf.rpartition('o')
  1321. msg = f'{self.session.sid}{max_ts}'.encode('utf-8')
  1322. if max_ts:
  1323. try:
  1324. if int(max_ts) < int(time.time()):
  1325. return False
  1326. except ValueError:
  1327. return False
  1328. hm_expected = hmac.new(secret.encode('ascii'), msg, hashlib.sha1).hexdigest()
  1329. return consteq(hm, hm_expected)
  1330. def default_context(self):
  1331. return dict(get_default_session()['context'], lang=self.default_lang())
  1332. def default_lang(self):
  1333. """Returns default user language according to request specification
  1334. :returns: Preferred language if specified or 'en_US'
  1335. :rtype: str
  1336. """
  1337. return self.best_lang or DEFAULT_LANG
  1338. def get_http_params(self):
  1339. """
  1340. Extract key=value pairs from the query string and the forms
  1341. present in the body (both application/x-www-form-urlencoded and
  1342. multipart/form-data).
  1343. :returns: The merged key-value pairs.
  1344. :rtype: dict
  1345. """
  1346. params = {
  1347. **self.httprequest.args,
  1348. **self.httprequest.form,
  1349. **self.httprequest.files
  1350. }
  1351. return params
  1352. def get_json_data(self):
  1353. return json.loads(self.httprequest.get_data(as_text=True))
  1354. def _get_profiler_context_manager(self):
  1355. """
  1356. Get a profiler when the profiling is enabled and the requested
  1357. URL is profile-safe. Otherwise, get a context-manager that does
  1358. nothing.
  1359. """
  1360. if self.session.profile_session and self.db:
  1361. if self.session.profile_expiration < str(datetime.now()):
  1362. # avoid having session profiling for too long if user forgets to disable profiling
  1363. self.session.profile_session = None
  1364. _logger.warning("Profiling expiration reached, disabling profiling")
  1365. elif 'set_profiling' in self.httprequest.path:
  1366. _logger.debug("Profiling disabled on set_profiling route")
  1367. elif self.httprequest.path.startswith('/websocket'):
  1368. _logger.debug("Profiling disabled for websocket")
  1369. elif odoo.evented:
  1370. # only longpolling should be in a evented server, but this is an additional safety
  1371. _logger.debug("Profiling disabled for evented server")
  1372. else:
  1373. try:
  1374. return profiler.Profiler(
  1375. db=self.db,
  1376. description=self.httprequest.full_path,
  1377. profile_session=self.session.profile_session,
  1378. collectors=self.session.profile_collectors,
  1379. params=self.session.profile_params,
  1380. )
  1381. except Exception:
  1382. _logger.exception("Failure during Profiler creation")
  1383. self.session.profile_session = None
  1384. return contextlib.nullcontext()
  1385. def _inject_future_response(self, response):
  1386. response.headers.extend(self.future_response.headers)
  1387. return response
  1388. def make_response(self, data, headers=None, cookies=None, status=200):
  1389. """ Helper for non-HTML responses, or HTML responses with custom
  1390. response headers or cookies.
  1391. While handlers can just return the HTML markup of a page they want to
  1392. send as a string if non-HTML data is returned they need to create a
  1393. complete response object, or the returned data will not be correctly
  1394. interpreted by the clients.
  1395. :param str data: response body
  1396. :param int status: http status code
  1397. :param headers: HTTP headers to set on the response
  1398. :type headers: ``[(name, value)]``
  1399. :param collections.abc.Mapping cookies: cookies to set on the client
  1400. :returns: a response object.
  1401. :rtype: :class:`~odoo.http.Response`
  1402. """
  1403. response = Response(data, status=status, headers=headers)
  1404. if cookies:
  1405. for k, v in cookies.items():
  1406. response.set_cookie(k, v)
  1407. return response
  1408. def make_json_response(self, data, headers=None, cookies=None, status=200):
  1409. """ Helper for JSON responses, it json-serializes ``data`` and
  1410. sets the Content-Type header accordingly if none is provided.
  1411. :param data: the data that will be json-serialized into the response body
  1412. :param int status: http status code
  1413. :param List[(str, str)] headers: HTTP headers to set on the response
  1414. :param collections.abc.Mapping cookies: cookies to set on the client
  1415. :rtype: :class:`~odoo.http.Response`
  1416. """
  1417. data = json.dumps(data, ensure_ascii=False, default=json_default)
  1418. headers = werkzeug.datastructures.Headers(headers)
  1419. headers['Content-Length'] = len(data)
  1420. if 'Content-Type' not in headers:
  1421. headers['Content-Type'] = 'application/json; charset=utf-8'
  1422. return self.make_response(data, headers.to_wsgi_list(), cookies, status)
  1423. def not_found(self, description=None):
  1424. """ Shortcut for a `HTTP 404
  1425. <http://tools.ietf.org/html/rfc7231#section-6.5.4>`_ (Not Found)
  1426. response
  1427. """
  1428. return NotFound(description)
  1429. def redirect(self, location, code=303, local=True):
  1430. # compatibility, Werkzeug support URL as location
  1431. if isinstance(location, URL):
  1432. location = location.to_url()
  1433. if local:
  1434. location = '/' + url_parse(location).replace(scheme='', netloc='').to_url().lstrip('/\\')
  1435. if self.db:
  1436. return self.env['ir.http']._redirect(location, code)
  1437. return werkzeug.utils.redirect(location, code, Response=Response)
  1438. def redirect_query(self, location, query=None, code=303, local=True):
  1439. if query:
  1440. location += '?' + url_encode(query)
  1441. return self.redirect(location, code=code, local=local)
  1442. def render(self, template, qcontext=None, lazy=True, **kw):
  1443. """ Lazy render of a QWeb template.
  1444. The actual rendering of the given template will occur at then end of
  1445. the dispatching. Meanwhile, the template and/or qcontext can be
  1446. altered or even replaced by a static response.
  1447. :param str template: template to render
  1448. :param dict qcontext: Rendering context to use
  1449. :param bool lazy: whether the template rendering should be deferred
  1450. until the last possible moment
  1451. :param dict kw: forwarded to werkzeug's Response object
  1452. """
  1453. response = Response(template=template, qcontext=qcontext, **kw)
  1454. if not lazy:
  1455. return response.render()
  1456. return response
  1457. def reroute(self, path, query_string=None):
  1458. """
  1459. Rewrite the current request URL using the new path and query
  1460. string. This act as a light redirection, it does not return a
  1461. 3xx responses to the browser but still change the current URL.
  1462. """
  1463. # WSGI encoding dance https://peps.python.org/pep-3333/#unicode-issues
  1464. if isinstance(path, str):
  1465. path = path.encode('utf-8')
  1466. path = path.decode('latin1', 'replace')
  1467. if query_string is None:
  1468. query_string = request.httprequest.environ['QUERY_STRING']
  1469. # Change the WSGI environment
  1470. environ = self.httprequest._HTTPRequest__environ.copy()
  1471. environ['PATH_INFO'] = path
  1472. environ['QUERY_STRING'] = query_string
  1473. environ['RAW_URI'] = f'{path}?{query_string}'
  1474. # REQUEST_URI left as-is so it still contains the original URI
  1475. # Create and expose a new request from the modified WSGI env
  1476. httprequest = HTTPRequest(environ)
  1477. threading.current_thread().url = httprequest.url
  1478. self.httprequest = httprequest
  1479. def _save_session(self):
  1480. """ Save a modified session on disk. """
  1481. sess = self.session
  1482. if not sess.can_save:
  1483. return
  1484. if sess.should_rotate:
  1485. root.session_store.rotate(sess, self.env) # it saves
  1486. elif sess.is_dirty:
  1487. root.session_store.save(sess)
  1488. cookie_sid = self.cookies.get('session_id')
  1489. if sess.is_dirty or cookie_sid != sess.sid:
  1490. self.future_response.set_cookie('session_id', sess.sid, max_age=get_session_max_inactivity(self.env), httponly=True)
  1491. def _set_request_dispatcher(self, rule):
  1492. routing = rule.endpoint.routing
  1493. dispatcher_cls = _dispatchers[routing['type']]
  1494. if (not is_cors_preflight(self, rule.endpoint)
  1495. and not dispatcher_cls.is_compatible_with(self)):
  1496. compatible_dispatchers = [
  1497. disp.routing_type
  1498. for disp in _dispatchers.values()
  1499. if disp.is_compatible_with(self)
  1500. ]
  1501. raise BadRequest(f"Request inferred type is compatible with {compatible_dispatchers} but {routing['routes'][0]!r} is type={routing['type']!r}.")
  1502. self.dispatcher = dispatcher_cls(self)
  1503. # =====================================================
  1504. # Routing
  1505. # =====================================================
  1506. def _serve_static(self):
  1507. """ Serve a static file from the file system. """
  1508. module, _, path = self.httprequest.path[1:].partition('/static/')
  1509. try:
  1510. directory = root.statics[module]
  1511. filepath = werkzeug.security.safe_join(directory, path)
  1512. debug = (
  1513. 'assets' in self.session.debug and
  1514. ' wkhtmltopdf ' not in self.httprequest.user_agent.string
  1515. )
  1516. res = Stream.from_path(filepath, public=True).get_response(
  1517. max_age=0 if debug else STATIC_CACHE,
  1518. content_security_policy=None,
  1519. )
  1520. root.set_csp(res)
  1521. return res
  1522. except KeyError:
  1523. raise NotFound(f'Module "{module}" not found.\n')
  1524. except OSError: # cover both missing file and invalid permissions
  1525. raise NotFound(f'File "{path}" not found in module {module}.\n')
  1526. def _serve_nodb(self):
  1527. """
  1528. Dispatch the request to its matching controller in a
  1529. database-free environment.
  1530. """
  1531. router = root.nodb_routing_map.bind_to_environ(self.httprequest.environ)
  1532. rule, args = router.match(return_rule=True)
  1533. self._set_request_dispatcher(rule)
  1534. self.dispatcher.pre_dispatch(rule, args)
  1535. response = self.dispatcher.dispatch(rule.endpoint, args)
  1536. self.dispatcher.post_dispatch(response)
  1537. return response
  1538. def _serve_db(self):
  1539. """
  1540. Prepare the user session and load the ORM before forwarding the
  1541. request to ``_serve_ir_http``.
  1542. """
  1543. cr_readwrite = None
  1544. rule = None
  1545. args = None
  1546. not_found = None
  1547. # reuse the same cursor for building+checking the registry and
  1548. # for matching the controller endpoint
  1549. try:
  1550. self.registry, cr_readwrite = self._open_registry()
  1551. threading.current_thread().dbname = self.registry.db_name
  1552. self.env = odoo.api.Environment(cr_readwrite, self.session.uid, self.session.context)
  1553. try:
  1554. rule, args = self.registry['ir.http']._match(self.httprequest.path)
  1555. except NotFound as not_found_exc:
  1556. not_found = not_found_exc
  1557. finally:
  1558. if cr_readwrite is not None:
  1559. cr_readwrite.close()
  1560. if not_found:
  1561. # no controller endpoint matched -> fallback or 404
  1562. return self._transactioning(
  1563. functools.partial(self._serve_ir_http_fallback, not_found),
  1564. readonly=True,
  1565. )
  1566. # a controller endpoint matched -> dispatch it the request
  1567. self._set_request_dispatcher(rule)
  1568. readonly = rule.endpoint.routing['readonly']
  1569. if callable(readonly):
  1570. readonly = readonly(rule.endpoint.func.__self__)
  1571. return self._transactioning(
  1572. functools.partial(self._serve_ir_http, rule, args),
  1573. readonly=readonly,
  1574. )
  1575. def _serve_ir_http_fallback(self, not_found):
  1576. """
  1577. Called when no controller match the request path. Delegate to
  1578. ``ir.http._serve_fallback`` to give modules the opportunity to
  1579. find an alternative way to serve the request. In case no module
  1580. provided a response, a generic 404 - Not Found page is returned.
  1581. """
  1582. self.params = self.get_http_params()
  1583. response = self.registry['ir.http']._serve_fallback()
  1584. if response:
  1585. self.registry['ir.http']._post_dispatch(response)
  1586. return response
  1587. no_fallback = NotFound()
  1588. no_fallback.__context__ = not_found # During handling of {not_found}, {no_fallback} occurred:
  1589. no_fallback.error_response = self.registry['ir.http']._handle_error(no_fallback)
  1590. raise no_fallback
  1591. def _serve_ir_http(self, rule, args):
  1592. """
  1593. Called when a controller match the request path. Delegate to
  1594. ``ir.http`` to serve a response.
  1595. """
  1596. self.registry['ir.http']._authenticate(rule.endpoint)
  1597. self.registry['ir.http']._pre_dispatch(rule, args)
  1598. response = self.dispatcher.dispatch(rule.endpoint, args)
  1599. self.registry['ir.http']._post_dispatch(response)
  1600. return response
  1601. def _transactioning(self, func, readonly):
  1602. """
  1603. Call ``func`` within a new SQL transaction.
  1604. If ``func`` performs a write query (insert/update/delete) on a
  1605. read-only transaction, the transaction is rolled back, and
  1606. ``func`` is called again in a read-write transaction.
  1607. Other errors are handled by ``ir.http._handle_error`` within
  1608. the same transaction.
  1609. Note: This function does not reset any state set on ``request``
  1610. and ``request.env`` upon returning. Therefore, any recordset
  1611. set on request during one transaction WILL NOT be usable inside
  1612. the following transactions unless the recordset is reset with
  1613. ``with_env(request.env)``. This is especially a concern between
  1614. ``_match`` and other ``ir.http`` methods, as ``_match`` is
  1615. called inside its own dedicated transaction.
  1616. """
  1617. for readonly_cr in (True, False) if readonly else (False,):
  1618. threading.current_thread().cursor_mode = (
  1619. 'ro' if readonly_cr
  1620. else 'ro->rw' if readonly
  1621. else 'rw'
  1622. )
  1623. with contextlib.closing(self.registry.cursor(readonly=readonly_cr)) as cr:
  1624. self.env = self.env(cr=cr)
  1625. try:
  1626. return service_model.retrying(func, env=self.env)
  1627. except psycopg2.errors.ReadOnlySqlTransaction as exc:
  1628. _logger.warning("%s, retrying with a read/write cursor", exc.args[0].rstrip(), exc_info=True)
  1629. continue
  1630. except Exception as exc:
  1631. if isinstance(exc, HTTPException) and exc.code is None:
  1632. raise # bubble up to odoo.http.Application.__call__
  1633. if 'werkzeug' in config['dev_mode'] and self.dispatcher.routing_type != 'json':
  1634. raise # bubble up to werkzeug.debug.DebuggedApplication
  1635. if not hasattr(exc, 'error_response'):
  1636. exc.error_response = self.registry['ir.http']._handle_error(exc)
  1637. raise
  1638. # =========================================================
  1639. # Core type-specialized dispatchers
  1640. # =========================================================
  1641. _dispatchers = {}
  1642. class Dispatcher(ABC):
  1643. routing_type: str
  1644. @classmethod
  1645. def __init_subclass__(cls):
  1646. super().__init_subclass__()
  1647. _dispatchers[cls.routing_type] = cls
  1648. def __init__(self, request):
  1649. self.request = request
  1650. @classmethod
  1651. @abstractmethod
  1652. def is_compatible_with(cls, request):
  1653. """
  1654. Determine if the current request is compatible with this
  1655. dispatcher.
  1656. """
  1657. def pre_dispatch(self, rule, args):
  1658. """
  1659. Prepare the system before dispatching the request to its
  1660. controller. This method is often overridden in ir.http to
  1661. extract some info from the request query-string or headers and
  1662. to save them in the session or in the context.
  1663. """
  1664. routing = rule.endpoint.routing
  1665. self.request.session.can_save = routing.get('save_session', True)
  1666. set_header = self.request.future_response.headers.set
  1667. cors = routing.get('cors')
  1668. if cors:
  1669. set_header('Access-Control-Allow-Origin', cors)
  1670. set_header('Access-Control-Allow-Methods', (
  1671. 'POST' if routing['type'] == 'json'
  1672. else ', '.join(routing['methods'] or ['GET', 'POST'])
  1673. ))
  1674. if cors and self.request.httprequest.method == 'OPTIONS':
  1675. set_header('Access-Control-Max-Age', CORS_MAX_AGE)
  1676. set_header('Access-Control-Allow-Headers',
  1677. 'Origin, X-Requested-With, Content-Type, Accept, Authorization')
  1678. werkzeug.exceptions.abort(Response(status=204))
  1679. if 'max_content_length' in routing:
  1680. max_content_length = routing['max_content_length']
  1681. if callable(max_content_length):
  1682. max_content_length = max_content_length(rule.endpoint.func.__self__)
  1683. self.request.httprequest.max_content_length = max_content_length
  1684. @abstractmethod
  1685. def dispatch(self, endpoint, args):
  1686. """
  1687. Extract the params from the request's body and call the
  1688. endpoint. While it is preferred to override ir.http._pre_dispatch
  1689. and ir.http._post_dispatch, this method can be override to have
  1690. a tight control over the dispatching.
  1691. """
  1692. def post_dispatch(self, response):
  1693. """
  1694. Manipulate the HTTP response to inject various headers, also
  1695. save the session when it is dirty.
  1696. """
  1697. self.request._save_session()
  1698. self.request._inject_future_response(response)
  1699. root.set_csp(response)
  1700. @abstractmethod
  1701. def handle_error(self, exc: Exception) -> collections.abc.Callable:
  1702. """
  1703. Transform the exception into a valid HTTP response. Called upon
  1704. any exception while serving a request.
  1705. """
  1706. class HttpDispatcher(Dispatcher):
  1707. routing_type = 'http'
  1708. @classmethod
  1709. def is_compatible_with(cls, request):
  1710. return True
  1711. def dispatch(self, endpoint, args):
  1712. """
  1713. Perform http-related actions such as deserializing the request
  1714. body and query-string and checking cors/csrf while dispatching a
  1715. request to a ``type='http'`` route.
  1716. See :meth:`~odoo.http.Response.load` method for the compatible
  1717. endpoint return types.
  1718. """
  1719. self.request.params = dict(self.request.get_http_params(), **args)
  1720. # Check for CSRF token for relevant requests
  1721. if self.request.httprequest.method not in CSRF_FREE_METHODS and endpoint.routing.get('csrf', True):
  1722. if not self.request.db:
  1723. return self.request.redirect('/web/database/selector')
  1724. token = self.request.params.pop('csrf_token', None)
  1725. if not self.request.validate_csrf(token):
  1726. if token is not None:
  1727. _logger.warning("CSRF validation failed on path '%s'", self.request.httprequest.path)
  1728. else:
  1729. _logger.warning(MISSING_CSRF_WARNING, request.httprequest.path)
  1730. raise werkzeug.exceptions.BadRequest('Session expired (invalid CSRF token)')
  1731. if self.request.db:
  1732. return self.request.registry['ir.http']._dispatch(endpoint)
  1733. else:
  1734. return endpoint(**self.request.params)
  1735. def handle_error(self, exc: Exception) -> collections.abc.Callable:
  1736. """
  1737. Handle any exception that occurred while dispatching a request
  1738. to a `type='http'` route. Also handle exceptions that occurred
  1739. when no route matched the request path, when no fallback page
  1740. could be delivered and that the request ``Content-Type`` was not
  1741. json.
  1742. :param Exception exc: the exception that occurred.
  1743. :returns: a WSGI application
  1744. """
  1745. if isinstance(exc, SessionExpiredException):
  1746. session = self.request.session
  1747. was_connected = session.uid is not None
  1748. session.logout(keep_db=True)
  1749. response = self.request.redirect_query('/web/login', {'redirect': self.request.httprequest.full_path})
  1750. if was_connected:
  1751. root.session_store.rotate(session, self.request.env)
  1752. response.set_cookie('session_id', session.sid, max_age=get_session_max_inactivity(self.env), httponly=True)
  1753. return response
  1754. return (exc if isinstance(exc, HTTPException)
  1755. else Forbidden(exc.args[0]) if isinstance(exc, (AccessDenied, AccessError))
  1756. else BadRequest(exc.args[0]) if isinstance(exc, UserError)
  1757. else InternalServerError() # hide the real error
  1758. )
  1759. class JsonRPCDispatcher(Dispatcher):
  1760. routing_type = 'json'
  1761. def __init__(self, request):
  1762. super().__init__(request)
  1763. self.jsonrequest = {}
  1764. self.request_id = None
  1765. @classmethod
  1766. def is_compatible_with(cls, request):
  1767. return request.httprequest.mimetype in JSON_MIMETYPES
  1768. def dispatch(self, endpoint, args):
  1769. """
  1770. `JSON-RPC 2 <http://www.jsonrpc.org/specification>`_ over HTTP.
  1771. Our implementation differs from the specification on two points:
  1772. 1. The ``method`` member of the JSON-RPC request payload is
  1773. ignored as the HTTP path is already used to route the request
  1774. to the controller.
  1775. 2. We only support parameter structures by-name, i.e. the
  1776. ``params`` member of the JSON-RPC request payload MUST be a
  1777. JSON Object and not a JSON Array.
  1778. In addition, it is possible to pass a context that replaces
  1779. the session context via a special ``context`` argument that is
  1780. removed prior to calling the endpoint.
  1781. Successful request::
  1782. --> {"jsonrpc": "2.0", "method": "call", "params": {"arg1": "val1" }, "id": null}
  1783. <-- {"jsonrpc": "2.0", "result": { "res1": "val1" }, "id": null}
  1784. Request producing a error::
  1785. --> {"jsonrpc": "2.0", "method": "call", "params": {"arg1": "val1" }, "id": null}
  1786. <-- {"jsonrpc": "2.0", "error": {"code": 1, "message": "End user error message.", "data": {"code": "codestring", "debug": "traceback" } }, "id": null}
  1787. """
  1788. try:
  1789. self.jsonrequest = self.request.get_json_data()
  1790. self.request_id = self.jsonrequest.get('id')
  1791. except ValueError:
  1792. # must use abort+Response to bypass handle_error
  1793. werkzeug.exceptions.abort(Response("Invalid JSON data", status=400))
  1794. except AttributeError:
  1795. # must use abort+Response to bypass handle_error
  1796. werkzeug.exceptions.abort(Response("Invalid JSON-RPC data", status=400))
  1797. self.request.params = dict(self.jsonrequest.get('params', {}), **args)
  1798. if self.request.db:
  1799. result = self.request.registry['ir.http']._dispatch(endpoint)
  1800. else:
  1801. result = endpoint(**self.request.params)
  1802. return self._response(result)
  1803. def handle_error(self, exc: Exception) -> collections.abc.Callable:
  1804. """
  1805. Handle any exception that occurred while dispatching a request to
  1806. a `type='json'` route. Also handle exceptions that occurred when
  1807. no route matched the request path, that no fallback page could
  1808. be delivered and that the request ``Content-Type`` was json.
  1809. :param exc: the exception that occurred.
  1810. :returns: a WSGI application
  1811. """
  1812. error = {
  1813. 'code': 200, # this code is the JSON-RPC level code, it is
  1814. # distinct from the HTTP status code. This
  1815. # code is ignored and the value 200 (while
  1816. # misleading) is totally arbitrary.
  1817. 'message': "Odoo Server Error",
  1818. 'data': serialize_exception(exc),
  1819. }
  1820. if isinstance(exc, NotFound):
  1821. error['code'] = 404
  1822. error['message'] = "404: Not Found"
  1823. elif isinstance(exc, SessionExpiredException):
  1824. error['code'] = 100
  1825. error['message'] = "Odoo Session Expired"
  1826. return self._response(error=error)
  1827. def _response(self, result=None, error=None):
  1828. response = {'jsonrpc': '2.0', 'id': self.request_id}
  1829. if error is not None:
  1830. response['error'] = error
  1831. if result is not None:
  1832. response['result'] = result
  1833. return self.request.make_json_response(response)
  1834. # =========================================================
  1835. # WSGI Entry Point
  1836. # =========================================================
  1837. class Application:
  1838. """ Odoo WSGI application """
  1839. # See also: https://www.python.org/dev/peps/pep-3333
  1840. @lazy_property
  1841. def statics(self):
  1842. """
  1843. Map module names to their absolute ``static`` path on the file
  1844. system.
  1845. """
  1846. mod2path = {}
  1847. for addons_path in odoo.addons.__path__:
  1848. for module in os.listdir(addons_path):
  1849. manifest = get_manifest(module)
  1850. static_path = opj(addons_path, module, 'static')
  1851. if (manifest
  1852. and (manifest['installable'] or manifest['assets'])
  1853. and os.path.isdir(static_path)):
  1854. mod2path[module] = static_path
  1855. return mod2path
  1856. def get_static_file(self, url, host=''):
  1857. """
  1858. Get the full-path of the file if the url resolves to a local
  1859. static file, otherwise return None.
  1860. Without the second host parameters, ``url`` must be an absolute
  1861. path, others URLs are considered faulty.
  1862. With the second host parameters, ``url`` can also be a full URI
  1863. and the authority found in the URL (if any) is validated against
  1864. the given ``host``.
  1865. """
  1866. netloc, path = urlparse(url)[1:3]
  1867. try:
  1868. path_netloc, module, static, resource = path.split('/', 3)
  1869. except ValueError:
  1870. return None
  1871. if ((netloc and netloc != host) or (path_netloc and path_netloc != host)):
  1872. return None
  1873. if (module not in self.statics or static != 'static' or not resource):
  1874. return None
  1875. try:
  1876. return file_path(f'{module}/static/{resource}')
  1877. except FileNotFoundError:
  1878. return None
  1879. @lazy_property
  1880. def nodb_routing_map(self):
  1881. nodb_routing_map = werkzeug.routing.Map(strict_slashes=False, converters=None)
  1882. for url, endpoint in _generate_routing_rules([''] + odoo.conf.server_wide_modules, nodb_only=True):
  1883. routing = submap(endpoint.routing, ROUTING_KEYS)
  1884. if routing['methods'] is not None and 'OPTIONS' not in routing['methods']:
  1885. routing['methods'] = [*routing['methods'], 'OPTIONS']
  1886. rule = werkzeug.routing.Rule(url, endpoint=endpoint, **routing)
  1887. rule.merge_slashes = False
  1888. nodb_routing_map.add(rule)
  1889. return nodb_routing_map
  1890. @lazy_property
  1891. def session_store(self):
  1892. path = odoo.tools.config.session_dir
  1893. _logger.debug('HTTP sessions stored in: %s', path)
  1894. return FilesystemSessionStore(path, session_class=Session, renew_missing=True)
  1895. def get_db_router(self, db):
  1896. if not db:
  1897. return self.nodb_routing_map
  1898. return request.env['ir.http'].routing_map()
  1899. @lazy_property
  1900. def geoip_city_db(self):
  1901. try:
  1902. return geoip2.database.Reader(config['geoip_city_db'])
  1903. except (OSError, maxminddb.InvalidDatabaseError):
  1904. _logger.debug(
  1905. "Couldn't load Geoip City file at %s. IP Resolver disabled.",
  1906. config['geoip_city_db'], exc_info=True
  1907. )
  1908. raise
  1909. @lazy_property
  1910. def geoip_country_db(self):
  1911. try:
  1912. return geoip2.database.Reader(config['geoip_country_db'])
  1913. except (OSError, maxminddb.InvalidDatabaseError) as exc:
  1914. _logger.debug("Couldn't load Geoip Country file (%s). Fallbacks on Geoip City.", exc,)
  1915. raise
  1916. def set_csp(self, response):
  1917. headers = response.headers
  1918. headers['X-Content-Type-Options'] = 'nosniff'
  1919. if 'Content-Security-Policy' in headers:
  1920. return
  1921. if not headers.get('Content-Type', '').startswith('image/'):
  1922. return
  1923. headers['Content-Security-Policy'] = "default-src 'none'"
  1924. def __call__(self, environ, start_response):
  1925. """
  1926. WSGI application entry point.
  1927. :param dict environ: container for CGI environment variables
  1928. such as the request HTTP headers, the source IP address and
  1929. the body as an io file.
  1930. :param callable start_response: function provided by the WSGI
  1931. server that this application must call in order to send the
  1932. HTTP response status line and the response headers.
  1933. """
  1934. current_thread = threading.current_thread()
  1935. current_thread.query_count = 0
  1936. current_thread.query_time = 0
  1937. current_thread.perf_t0 = time.time()
  1938. current_thread.cursor_mode = None
  1939. if hasattr(current_thread, 'dbname'):
  1940. del current_thread.dbname
  1941. if hasattr(current_thread, 'uid'):
  1942. del current_thread.uid
  1943. if odoo.tools.config['proxy_mode'] and environ.get("HTTP_X_FORWARDED_HOST"):
  1944. # The ProxyFix middleware has a side effect of updating the
  1945. # environ, see https://github.com/pallets/werkzeug/pull/2184
  1946. def fake_app(environ, start_response):
  1947. return []
  1948. def fake_start_response(status, headers):
  1949. return
  1950. ProxyFix(fake_app)(environ, fake_start_response)
  1951. with HTTPRequest(environ) as httprequest:
  1952. request = Request(httprequest)
  1953. _request_stack.push(request)
  1954. try:
  1955. request._post_init()
  1956. current_thread.url = httprequest.url
  1957. if self.get_static_file(httprequest.path):
  1958. response = request._serve_static()
  1959. elif request.db:
  1960. try:
  1961. with request._get_profiler_context_manager():
  1962. response = request._serve_db()
  1963. except RegistryError as e:
  1964. _logger.warning("Database or registry unusable, trying without", exc_info=e.__cause__)
  1965. request.db = None
  1966. request.session.logout()
  1967. if (httprequest.path.startswith('/odoo/')
  1968. or httprequest.path in (
  1969. '/odoo', '/web', '/web/login', '/test_http/ensure_db',
  1970. )):
  1971. # ensure_db() protected routes, remove ?db= from the query string
  1972. args_nodb = request.httprequest.args.copy()
  1973. args_nodb.pop('db', None)
  1974. request.reroute(httprequest.path, url_encode(args_nodb))
  1975. response = request._serve_nodb()
  1976. else:
  1977. response = request._serve_nodb()
  1978. return response(environ, start_response)
  1979. except Exception as exc:
  1980. # Valid (2xx/3xx) response returned via werkzeug.exceptions.abort.
  1981. if isinstance(exc, HTTPException) and exc.code is None:
  1982. response = exc.get_response()
  1983. HttpDispatcher(request).post_dispatch(response)
  1984. return response(environ, start_response)
  1985. # Logs the error here so the traceback starts with ``__call__``.
  1986. if hasattr(exc, 'loglevel'):
  1987. _logger.log(exc.loglevel, exc, exc_info=getattr(exc, 'exc_info', None))
  1988. elif isinstance(exc, HTTPException):
  1989. pass
  1990. elif isinstance(exc, SessionExpiredException):
  1991. _logger.info(exc)
  1992. elif isinstance(exc, (UserError, AccessError)):
  1993. _logger.warning(exc)
  1994. else:
  1995. _logger.error("Exception during request handling.", exc_info=True)
  1996. # Ensure there is always a WSGI handler attached to the exception.
  1997. if not hasattr(exc, 'error_response'):
  1998. exc.error_response = request.dispatcher.handle_error(exc)
  1999. return exc.error_response(environ, start_response)
  2000. finally:
  2001. _request_stack.pop()
  2002. root = Application()
上海开阖软件有限公司 沪ICP备12045867号-1