Coverage for adhoc-cicd-odoo-odoo / odoo / http.py: 26%

1402 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-09 18:15 +0000

1# Part of Odoo. See LICENSE file for full copyright and licensing details. 

2r"""\ 

3Odoo HTTP layer / WSGI application 

4 

5The main duty of this module is to prepare and dispatch all http 

6requests to their corresponding controllers: from a raw http request 

7arriving on the WSGI entrypoint to a :class:`~http.Request`: arriving at 

8a module controller with a fully setup ORM available. 

9 

10Application developers mostly know this module thanks to the 

11:class:`~odoo.http.Controller`: class and its companion the 

12:func:`~odoo.http.route`: method decorator. Together they are used to 

13register methods responsible of delivering web content to matching URLS. 

14 

15Those two are only the tip of the iceberg, below is a call graph that 

16shows the various processing layers each request passes through before 

17ending at the @route decorated endpoint. Hopefully, this call graph and 

18the attached function descriptions will help you understand this module. 

19 

20Here be dragons: 

21 

22 Application.__call__ 

23 if path is like '/<module>/static/<path>': 

24 Request._serve_static 

25 

26 elif not request.db: 

27 Request._serve_nodb 

28 App.nodb_routing_map.match 

29 Dispatcher.pre_dispatch 

30 Dispatcher.dispatch 

31 route_wrapper 

32 endpoint 

33 Dispatcher.post_dispatch 

34 

35 else: 

36 Request._serve_db 

37 env['ir.http']._match 

38 if not match: 

39 model.retrying(Request._serve_ir_http_fallback) 

40 env['ir.http']._serve_fallback 

41 env['ir.http']._post_dispatch 

42 else: 

43 model.retrying(Request._serve_ir_http) 

44 env['ir.http']._authenticate 

45 env['ir.http']._pre_dispatch 

46 Dispatcher.pre_dispatch 

47 Dispatcher.dispatch 

48 env['ir.http']._dispatch 

49 route_wrapper 

50 endpoint 

51 env['ir.http']._post_dispatch 

52 

53Application.__call__ 

54 WSGI entry point, it sanitizes the request, it wraps it in a werkzeug 

55 request and itself in an Odoo http request. The Odoo http request is 

56 exposed at ``http.request`` then it is forwarded to either 

57 ``_serve_static``, ``_serve_nodb`` or ``_serve_db`` depending on the 

58 request path and the presence of a database. It is also responsible of 

59 ensuring any error is properly logged and encapsuled in a HTTP error 

60 response. 

61 

62Request._serve_static 

63 Handle all requests to ``/<module>/static/<asset>`` paths, open the 

64 underlying file on the filesystem and stream it via 

65 :meth:``Request.send_file`` 

66 

67Request._serve_nodb 

68 Handle requests to ``@route(auth='none')`` endpoints when the user is 

69 not connected to a database. It performs limited operations, just 

70 matching the auth='none' endpoint using the request path and then it 

71 delegates to Dispatcher. 

72 

73Request._serve_db 

74 Handle all requests that are not static when it is possible to connect 

75 to a database. It opens a registry on the database, manage the request 

76 cursor and environment. The function decides whether to use a 

77 read-only or a read/write cursor for its operations: 

78 ``check_signaling``, ``match`` and ``serve_fallback`` are called using 

79 the same read-only cursor; ``_serve_ir_http`` is called reusing the 

80 same (but reset) read-only cursor, or a new read/write one. 

81 

82service.model.retrying 

83 Manage the cursor, the environment and exceptions that occured while 

84 executing the underlying function. They recover from various 

85 exceptions such as serialization errors and writes in read-only 

86 transactions. They catches all other exceptions and attach a http 

87 response to them (e.g. 500 - Internal Server Error) 

88 

89ir.http._match 

90 Match the controller endpoint that correspond to the request path. 

91 Beware that there is an important override for portal and website 

92 inside of the ``http_routing`` module. 

93 

94ir.http._serve_fallback 

95 Find alternative ways to serve a request when its path does not match 

96 any controller. The path could be matching an attachment URL, a blog 

97 page, etc. 

98 

99ir.http._authenticate 

100 Ensure the user on the current environment fulfill the requirement of 

101 ``@route(auth=...)``. Using the ORM outside of abstract models is 

102 unsafe prior of calling this function. 

103 

104ir.http._pre_dispatch/Dispatcher.pre_dispatch 

105 Prepare the system the handle the current request, often used to save 

106 some extra query-string parameters in the session (e.g. ?debug=1) 

107 

108ir.http._dispatch/Dispatcher.dispatch 

109 Deserialize the HTTP request body into ``request.params`` according to 

110 @route(type=...), call the controller endpoint, serialize its return 

111 value into an HTTP Response object. 

112 

113ir.http._post_dispatch/Dispatcher.post_dispatch 

114 Post process the response returned by the controller endpoint. Used to 

115 inject various headers such as Content-Security-Policy. 

116 

117ir.http._handle_error 

118 Not present in the call-graph, is called for un-managed exceptions (SE 

119 or RO) that occured inside of ``Request._retrying``. It returns a http 

120 response that wraps the error that occured. 

121 

122route_wrapper, closure of the http.route decorator 

123 Sanitize the request parameters, call the route endpoint and 

124 optionally coerce the endpoint result. 

125 

126endpoint 

127 The @route(...) decorated controller method. 

128""" 

129 

130import odoo.init # import first for core setup 

131 

132import base64 

133import collections.abc 

134import contextlib 

135import functools 

136import glob 

137import hashlib 

138import hmac 

139import importlib.metadata 

140import inspect 

141import json 

142import logging 

143import mimetypes 

144import os 

145import re 

146import threading 

147import time 

148import traceback 

149import warnings 

150from abc import ABC, abstractmethod 

151from datetime import datetime, timedelta 

152from hashlib import sha512 

153from http import HTTPStatus 

154from io import BytesIO 

155from os.path import join as opj 

156from pathlib import Path 

157from urllib.parse import urlparse 

158from zlib import adler32 

159 

160import babel.core 

161 

162try: 

163 import geoip2.database 

164 import geoip2.models 

165 import geoip2.errors 

166except ImportError: 

167 geoip2 = None 

168 

169try: 

170 import maxminddb 

171except ImportError: 

172 maxminddb = None 

173 

174import psycopg2 

175import werkzeug.datastructures 

176import werkzeug.exceptions 

177import werkzeug.local 

178import werkzeug.routing 

179import werkzeug.security 

180import werkzeug.wrappers 

181import werkzeug.wsgi 

182from werkzeug.urls import URL, url_parse, url_encode, url_quote 

183from werkzeug.exceptions import ( 

184 default_exceptions as werkzeug_default_exceptions, 

185 HTTPException, NotFound, UnsupportedMediaType, UnprocessableEntity, 

186 InternalServerError 

187) 

188try: 

189 from werkzeug.middleware.proxy_fix import ProxyFix as ProxyFix_ 

190 ProxyFix = functools.partial(ProxyFix_, x_for=1, x_proto=1, x_host=1) 

191except ImportError: 

192 from werkzeug.contrib.fixers import ProxyFix 

193 

194try: 

195 from werkzeug.utils import send_file as _send_file 

196except ImportError: 

197 from .tools._vendor.send_file import send_file as _send_file 

198 

199import odoo.addons 

200from .exceptions import UserError, AccessError, AccessDenied 

201from .modules import module as module_manager 

202from .modules.registry import Registry 

203from .service import security, model as service_model 

204from .service.server import thread_local 

205from .tools import (config, consteq, file_path, get_lang, json_default, 

206 parse_version, profiler, unique, exception_to_unicode) 

207from .tools.facade import Proxy, ProxyAttr, ProxyFunc 

208from .tools.func import filter_kwargs 

209from .tools.misc import submap, real_time 

210from .tools._vendor import sessions 

211from .tools._vendor.useragents import UserAgent 

212 

213 

214_logger = logging.getLogger(__name__) 

215 

216 

217# ========================================================= 

218# Const 

219# ========================================================= 

220 

221# The validity duration of a preflight response, one day. 

222CORS_MAX_AGE = 60 * 60 * 24 

223 

224# The HTTP methods that do not require a CSRF validation. 

225SAFE_HTTP_METHODS = ('GET', 'HEAD', 'OPTIONS', 'TRACE') 

226 

227# The default csrf token lifetime, a salt against BREACH, one year 

228CSRF_TOKEN_SALT = 60 * 60 * 24 * 365 

229 

230# The default lang to use when the browser doesn't specify it 

231DEFAULT_LANG = 'en_US' 

232 

233# The dictionary to initialise a new session with. 

234def get_default_session(): 

235 return { 

236 'context': {}, # 'lang': request.default_lang() # must be set at runtime 

237 'create_time': time.time(), 

238 'db': None, 

239 'debug': '', 

240 'login': None, 

241 'uid': None, 

242 'session_token': None, 

243 '_trace': [], 

244 'create_time': time.time(), 

245 } 

246 

247DEFAULT_MAX_CONTENT_LENGTH = 128 * 1024 * 1024 # 128MiB 

248 

249# Two empty objects used when the geolocalization failed. They have the 

250# sames attributes as real countries/cities except that accessing them 

251# evaluates to None. 

252if geoip2: 252 ↛ 256line 252 didn't jump to line 256 because the condition on line 252 was always true

253 GEOIP_EMPTY_COUNTRY = geoip2.models.Country({}) 

254 GEOIP_EMPTY_CITY = geoip2.models.City({}) 

255 

256MISSING_CSRF_WARNING = """\ 

257No CSRF validation token provided for path %r 

258 

259Odoo URLs are CSRF-protected by default (when accessed with unsafe 

260HTTP methods). See 

261https://www.odoo.com/documentation/master/developer/reference/addons/http.html#csrf 

262for more details. 

263 

264* if this endpoint is accessed through Odoo via py-QWeb form, embed a CSRF 

265 token in the form, Tokens are available via `request.csrf_token()` 

266 can be provided through a hidden input and must be POST-ed named 

267 `csrf_token` e.g. in your form add: 

268 <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> 

269 

270* if the form is generated or posted in javascript, the token value is 

271 available as `csrf_token` on `web.core` and as the `csrf_token` 

272 value in the default js-qweb execution context 

273 

274* if the form is accessed by an external third party (e.g. REST API 

275 endpoint, payment gateway callback) you will need to disable CSRF 

276 protection (and implement your own protection if necessary) by 

277 passing the `csrf=False` parameter to the `route` decorator. 

278""" 

279 

280NOT_FOUND_NODB = """\ 

281<!DOCTYPE html> 

282<title>404 Not Found</title> 

283<h1>Not Found</h1> 

284<p>No database is selected and the requested URL was not found in the server-wide controllers.</p> 

285<p>Please verify the hostname, <a href=/web/login>login</a> and try again.</p> 

286 

287<!-- Alternatively, use the X-Odoo-Database header. --> 

288""" 

289 

290# The @route arguments to propagate from the decorated method to the 

291# routing rule. 

292ROUTING_KEYS = { 

293 'defaults', 'subdomain', 'build_only', 'strict_slashes', 'redirect_to', 

294 'alias', 'host', 'methods', 

295} 

296 

297if parse_version(importlib.metadata.version('werkzeug')) >= parse_version('2.0.2'): 297 ↛ 308line 297 didn't jump to line 308 because the condition on line 297 was always true

298 # Werkzeug 2.0.2 adds the websocket option. If a websocket request 

299 # (ws/wss) is trying to access an HTTP route, a WebsocketMismatch 

300 # exception is raised. On the other hand, Werkzeug 0.16 does not 

301 # support the websocket routing key. In order to bypass this issue, 

302 # let's add the websocket key only when appropriate. 

303 ROUTING_KEYS.add('websocket') 

304 

305# The default duration of a user session cookie. Inactive sessions are reaped 

306# server-side as well with a threshold that can be set via an optional 

307# config parameter `sessions.max_inactivity_seconds` (default: SESSION_LIFETIME) 

308SESSION_LIFETIME = 60 * 60 * 24 * 7 

309 

310# The default duration (3h) before a session is rotated, changing the 

311# session id (also on the cookie) but keeping the same content. 

312SESSION_ROTATION_INTERVAL = 60 * 60 * 3 

313 

314# URL paths for which automatic session rotation is disabled. 

315SESSION_ROTATION_EXCLUDED_PATHS = ( 

316 '/websocket/peek_notifications', 

317 '/websocket/update_bus_presence', 

318) 

319 

320# After a session is rotated, the session should be kept for a couple of 

321# seconds to account for network delay between multiple requests which are 

322# made at the same time and all use the same old cookie. 

323SESSION_DELETION_TIMER = 120 

324 

325# The amount of bytes of the session that will remain static and can be used 

326# for calculating the csrf token and be stored inside the database. 

327STORED_SESSION_BYTES = 42 

328 

329# The cache duration for static content from the filesystem, one week. 

330STATIC_CACHE = 60 * 60 * 24 * 7 

331 

332# The cache duration for content where the url uniquely identifies the 

333# content (usually using a hash), one year. 

334STATIC_CACHE_LONG = 60 * 60 * 24 * 365 

335 

336 

337# ========================================================= 

338# Helpers 

339# ========================================================= 

340 

341class RegistryError(RuntimeError): 

342 pass 

343 

344 

345class SessionExpiredException(Exception): 

346 http_status = HTTPStatus.FORBIDDEN 

347 

348 

349def content_disposition(filename, disposition_type='attachment'): 

350 """ 

351 Craft a ``Content-Disposition`` header, see :rfc:`6266`. 

352 

353 :param filename: The name of the file, should that file be saved on 

354 disk by the browser. 

355 :param disposition_type: Tell the browser what to do with the file, 

356 either ``"attachment"`` to save the file on disk, 

357 either ``"inline"`` to display the file. 

358 """ 

359 if disposition_type not in ('attachment', 'inline'): 

360 e = f"Invalid disposition_type: {disposition_type!r}" 

361 raise ValueError(e) 

362 return "{}; filename*=UTF-8''{}".format( 

363 disposition_type, 

364 url_quote(filename, safe='', unsafe='()<>@,;:"/[]?={}\\*\'%') # RFC6266 

365 ) 

366 

367 

368def db_list(force=False, host=None): 

369 """ 

370 Get the list of available databases. 

371 

372 :param bool force: See :func:`~odoo.service.db.list_dbs`: 

373 :param host: The Host used to replace %h and %d in the dbfilters 

374 regexp. Taken from the current request when omitted. 

375 :returns: the list of available databases 

376 :rtype: List[str] 

377 """ 

378 try: 

379 dbs = odoo.service.db.list_dbs(force) 

380 except psycopg2.OperationalError: 

381 return [] 

382 return db_filter(dbs, host) 

383 

384 

385def db_filter(dbs, host=None): 

386 """ 

387 Return the subset of ``dbs`` that match the dbfilter or the dbname 

388 server configuration. In case neither are configured, return ``dbs`` 

389 as-is. 

390 

391 :param Iterable[str] dbs: The list of database names to filter. 

392 :param host: The Host used to replace %h and %d in the dbfilters 

393 regexp. Taken from the current request when omitted. 

394 :returns: The original list filtered. 

395 :rtype: List[str] 

396 """ 

397 

398 if config['dbfilter']: 

399 # host 

400 # ----------- 

401 # www.example.com:80 

402 # ------- 

403 # domain 

404 if host is None: 

405 host = request.httprequest.environ.get('HTTP_HOST', '') 

406 host = host.partition(':')[0] 

407 if host.startswith('www.'): 

408 host = host[4:] 

409 domain = host.partition('.')[0] 

410 

411 dbfilter_re = re.compile( 

412 config["dbfilter"].replace("%h", re.escape(host)) 

413 .replace("%d", re.escape(domain))) 

414 return [db for db in dbs if dbfilter_re.match(db)] 

415 

416 if config['db_name']: 

417 # In case --db-filter is not provided and --database is passed, Odoo will 

418 # use the value of --database as a comma separated list of exposed databases. 

419 return sorted(set(config['db_name']).intersection(dbs)) 

420 

421 return list(dbs) 

422 

423 

424def dispatch_rpc(service_name, method, params): 

425 """ 

426 Perform a RPC call. 

427 

428 :param str service_name: either "common", "db" or "object". 

429 :param str method: the method name of the given service to execute 

430 :param Mapping params: the keyword arguments for method call 

431 :return: the return value of the called method 

432 :rtype: Any 

433 """ 

434 rpc_dispatchers = { 

435 'common': odoo.service.common.dispatch, 

436 'db': odoo.service.db.dispatch, 

437 'object': odoo.service.model.dispatch, 

438 } 

439 

440 with borrow_request(): 

441 threading.current_thread().uid = None 

442 threading.current_thread().dbname = None 

443 

444 dispatch = rpc_dispatchers[service_name] 

445 return dispatch(method, params) 

446 

447 

448def get_session_max_inactivity(env): 

449 if not env or env.cr._closed: 

450 return SESSION_LIFETIME 

451 

452 ICP = env['ir.config_parameter'].sudo() 

453 

454 try: 

455 return int(ICP.get_param('sessions.max_inactivity_seconds', SESSION_LIFETIME)) 

456 except ValueError: 

457 _logger.warning("Invalid value for 'sessions.max_inactivity_seconds', using default value.") 

458 return SESSION_LIFETIME 

459 

460 

461def is_cors_preflight(request, endpoint): 

462 return request.httprequest.method == 'OPTIONS' and endpoint.routing.get('cors', False) 

463 

464 

465def serialize_exception(exception, *, message=None, arguments=None): 

466 name = type(exception).__name__ 

467 module = type(exception).__module__ 

468 

469 return { 

470 'name': f'{module}.{name}' if module else name, 

471 'message': exception_to_unicode(exception) if message is None else message, 

472 'arguments': exception.args if arguments is None else arguments, 

473 'context': getattr(exception, 'context', {}), 

474 'debug': ''.join(traceback.format_exception(exception)), 

475 } 

476 

477 

478# ========================================================= 

479# File Streaming 

480# ========================================================= 

481 

482 

483class Stream: 

484 """ 

485 Send the content of a file, an attachment or a binary field via HTTP 

486 

487 This utility is safe, cache-aware and uses the best available 

488 streaming strategy. Works best with the --x-sendfile cli option. 

489 

490 Create a Stream via one of the constructors: :meth:`~from_path`:, or 

491 :meth:`~from_binary_field`:, generate the corresponding HTTP response 

492 object via :meth:`~get_response`:. 

493 

494 Instantiating a Stream object manually without using one of the 

495 dedicated constructors is discouraged. 

496 """ 

497 

498 type: str = '' # 'data' or 'path' or 'url' 

499 data = None 

500 path = None 

501 url = None 

502 

503 mimetype = None 

504 as_attachment = False 

505 download_name = None 

506 conditional = True 

507 etag = True 

508 last_modified = None 

509 max_age = None 

510 immutable = False 

511 size = None 

512 public = False 

513 

514 def __init__(self, **kwargs): 

515 # Remove class methods from the instances 

516 self.from_path = self.from_attachment = self.from_binary_field = None 

517 self.__dict__.update(kwargs) 

518 

519 @classmethod 

520 def from_path(cls, path, filter_ext=('',), public=False): 

521 """ 

522 Create a :class:`~Stream`: from an addon resource. 

523 

524 :param path: See :func:`~odoo.tools.file_path` 

525 :param filter_ext: See :func:`~odoo.tools.file_path` 

526 :param bool public: Advertise the resource as being cachable by 

527 intermediate proxies, otherwise only let the browser caches 

528 it. 

529 """ 

530 path = file_path(path, filter_ext) 

531 check = adler32(path.encode()) 

532 stat = os.stat(path) 

533 return cls( 

534 type='path', 

535 path=path, 

536 mimetype=mimetypes.guess_type(path)[0], 

537 download_name=os.path.basename(path), 

538 etag=f'{int(stat.st_mtime)}-{stat.st_size}-{check}', 

539 last_modified=stat.st_mtime, 

540 size=stat.st_size, 

541 public=public, 

542 ) 

543 

544 @classmethod 

545 def from_binary_field(cls, record, field_name): 

546 """ Create a :class:`~Stream`: from a binary field. """ 

547 data_b64 = record[field_name] 

548 data = base64.b64decode(data_b64) if data_b64 else b'' 

549 return cls( 

550 type='data', 

551 data=data, 

552 etag=request.env['ir.attachment']._compute_checksum(data), 

553 last_modified=record.write_date if record._log_access else None, 

554 size=len(data), 

555 public=record.env.user._is_public() # good enough 

556 ) 

557 

558 def read(self): 

559 """ Get the stream content as bytes. """ 

560 if self.type == 'url': 

561 raise ValueError("Cannot read an URL") 

562 

563 if self.type == 'data': 

564 return self.data 

565 

566 with open(self.path, 'rb') as file: 

567 return file.read() 

568 

569 def get_response( 

570 self, 

571 as_attachment=None, 

572 immutable=None, 

573 content_security_policy="default-src 'none'", 

574 **send_file_kwargs 

575 ): 

576 """ 

577 Create the corresponding :class:`~Response` for the current stream. 

578 

579 :param bool|None as_attachment: Indicate to the browser that it 

580 should offer to save the file instead of displaying it. 

581 :param bool|None immutable: Add the ``immutable`` directive to 

582 the ``Cache-Control`` response header, allowing intermediary 

583 proxies to aggressively cache the response. This option also 

584 set the ``max-age`` directive to 1 year. 

585 :param str|None content_security_policy: Optional value for the 

586 ``Content-Security-Policy`` (CSP) header. This header is 

587 used by browsers to allow/restrict the downloaded resource 

588 to itself perform new http requests. By default CSP is set 

589 to ``"default-scr 'none'"`` which restrict all requests. 

590 :param send_file_kwargs: Other keyword arguments to send to 

591 :func:`odoo.tools._vendor.send_file.send_file` instead of 

592 the stream sensitive values. Discouraged. 

593 """ 

594 assert self.type in ('url', 'data', 'path'), "Invalid type: {self.type!r}, should be 'url', 'data' or 'path'." 

595 assert getattr(self, self.type) is not None, "There is nothing to stream, missing {self.type!r} attribute." 

596 

597 if self.type == 'url': 

598 if self.max_age is not None: 

599 res = request.redirect(self.url, code=302, local=False) 

600 res.headers['Cache-Control'] = f'max-age={self.max_age}' 

601 return res 

602 return request.redirect(self.url, code=301, local=False) 

603 

604 if as_attachment is None: 

605 as_attachment = self.as_attachment 

606 if immutable is None: 

607 immutable = self.immutable 

608 

609 send_file_kwargs = { 

610 'mimetype': self.mimetype, 

611 'as_attachment': as_attachment, 

612 'download_name': self.download_name, 

613 'conditional': self.conditional, 

614 'etag': self.etag, 

615 'last_modified': self.last_modified, 

616 'max_age': STATIC_CACHE_LONG if immutable else self.max_age, 

617 'environ': request.httprequest.environ, 

618 'response_class': Response, 

619 **send_file_kwargs, 

620 } 

621 

622 if self.type == 'data': 

623 res = _send_file(BytesIO(self.data), **send_file_kwargs) 

624 else: # self.type == 'path' 

625 send_file_kwargs['use_x_sendfile'] = False 

626 if config['x_sendfile']: 

627 with contextlib.suppress(ValueError): # outside of the filestore 

628 fspath = Path(self.path).relative_to(opj(config['data_dir'], 'filestore')) 

629 x_accel_redirect = f'/web/filestore/{fspath}' 

630 send_file_kwargs['use_x_sendfile'] = True 

631 

632 res = _send_file(self.path, **send_file_kwargs) 

633 if 'X-Sendfile' in res.headers: 

634 res.headers['X-Accel-Redirect'] = x_accel_redirect 

635 

636 # In case of X-Sendfile/X-Accel-Redirect, the body is empty, 

637 # yet werkzeug gives the length of the file. This makes 

638 # NGINX wait for content that'll never arrive. 

639 res.headers['Content-Length'] = '0' 

640 

641 res.headers['X-Content-Type-Options'] = 'nosniff' 

642 

643 if content_security_policy: # see also Application.set_csp() 

644 res.headers['Content-Security-Policy'] = content_security_policy 

645 

646 if self.public: 

647 if (res.cache_control.max_age or 0) > 0: 

648 res.cache_control.public = True 

649 else: 

650 res.cache_control.pop('public', '') 

651 res.cache_control.private = True 

652 if immutable: 

653 res.cache_control['immutable'] = None # None sets the directive 

654 

655 return res 

656 

657 

658# ========================================================= 

659# Controller and routes 

660# ========================================================= 

661 

662class Controller: 

663 """ 

664 Class mixin that provide module controllers the ability to serve 

665 content over http and to be extended in child modules. 

666 

667 Each class :ref:`inheriting <python:tut-inheritance>` from 

668 :class:`~odoo.http.Controller` can use the :func:`~odoo.http.route`: 

669 decorator to route matching incoming web requests to decorated 

670 methods. 

671 

672 Like models, controllers can be extended by other modules. The 

673 extension mechanism is different because controllers can work in a 

674 database-free environment and therefore cannot use 

675 :class:~odoo.api.Registry:. 

676 

677 To *override* a controller, :ref:`inherit <python:tut-inheritance>` 

678 from its class, override relevant methods and re-expose them with 

679 :func:`~odoo.http.route`:. Please note that the decorators of all 

680 methods are combined, if the overriding method’s decorator has no 

681 argument all previous ones will be kept, any provided argument will 

682 override previously defined ones. 

683 

684 .. code-block: 

685 

686 class GreetingController(odoo.http.Controller): 

687 @route('/greet', type='http', auth='public') 

688 def greeting(self): 

689 return 'Hello' 

690 

691 class UserGreetingController(GreetingController): 

692 @route(auth='user') # override auth, keep path and type 

693 def greeting(self): 

694 return super().handler() 

695 """ 

696 children_classes = collections.defaultdict(list) # indexed by module 

697 

698 @classmethod 

699 def __init_subclass__(cls): 

700 super().__init_subclass__() 

701 if Controller in cls.__bases__: 

702 path = cls.__module__.split('.') 

703 module = path[2] if path[:2] == ['odoo', 'addons'] else '' 

704 Controller.children_classes[module].append(cls) 

705 

706 @property 

707 def env(self): 

708 return request.env if request else None 

709 

710 

711def route(route=None, **routing): 

712 """ 

713 Decorate a controller method in order to route incoming requests 

714 matching the given URL and options to the decorated method. 

715 

716 .. warning:: 

717 It is mandatory to re-decorate any method that is overridden in 

718 controller extensions but the arguments can be omitted. See 

719 :class:`~odoo.http.Controller` for more details. 

720 

721 :param Union[str, Iterable[str]] route: The paths that the decorated 

722 method is serving. Incoming HTTP request paths matching this 

723 route will be routed to this decorated method. See `werkzeug 

724 routing documentation <http://werkzeug.pocoo.org/docs/routing/>`_ 

725 for the format of route expressions. 

726 :param str type: The type of request, either ``'jsonrpc'`` or 

727 ``'http'``. It describes where to find the request parameters 

728 and how to serialize the response. 

729 :param str auth: The authentication method, one of the following: 

730 

731 * ``'user'``: The user must be authenticated and the current 

732 request will be executed using the rights of the user. 

733 * ``'bearer'``: The user is authenticated using an "Authorization" 

734 request header, using the Bearer scheme with an API token. 

735 The request will be executed with the permissions of the 

736 corresponding user. If the header is missing, the request 

737 must belong to an authentication session, as for the "user" 

738 authentication method. 

739 * ``'public'``: The user may or may not be authenticated. If he 

740 isn't, the current request will be executed using the shared 

741 Public user. 

742 * ``'none'``: The method is always active, even if there is no 

743 database. Mainly used by the framework and authentication 

744 modules. The request code will not have any facilities to 

745 access the current user. 

746 :param Iterable[str] methods: A list of http methods (verbs) this 

747 route applies to. If not specified, all methods are allowed. 

748 :param str cors: The Access-Control-Allow-Origin cors directive value. 

749 :param bool csrf: Whether CSRF protection should be enabled for the 

750 route. Enabled by default for ``'http'``-type requests, disabled 

751 by default for ``'jsonrpc'``-type requests. 

752 :param Union[bool, Callable[[registry, request], bool]] readonly: 

753 Whether this endpoint should open a cursor on a read-only 

754 replica instead of (by default) the primary read/write database. 

755 :param Callable[[Exception], Response] handle_params_access_error: 

756 Implement a custom behavior if an error occurred when retrieving 

757 the record from the URL parameters (access error or missing error). 

758 :param str captcha: The action name of the captcha. When set the 

759 request will be validated against a captcha implementation. Upon 

760 failing these requests will return a UserError. 

761 :param bool save_session: Whether it should set a session_id cookie 

762 on the http response and save dirty session on disk. ``False`` 

763 by default for ``auth='bearer'``. ``True`` by default otherwise. 

764 """ 

765 def decorator(endpoint): 

766 fname = f"<function {endpoint.__module__}.{endpoint.__name__}>" 

767 

768 # Sanitize the routing 

769 if routing.get('type') == 'json': 769 ↛ 770line 769 didn't jump to line 770 because the condition on line 769 was never true

770 warnings.warn( 

771 "Since 19.0, @route(type='json') is a deprecated alias to @route(type='jsonrpc')", 

772 DeprecationWarning, 

773 stacklevel=3, 

774 ) 

775 routing['type'] = 'jsonrpc' 

776 assert routing.get('type', 'http') in _dispatchers.keys(), \ 

777 f"@route(type={routing['type']!r}) is not one of {_dispatchers.keys()}" 

778 if route: 

779 routing['routes'] = [route] if isinstance(route, str) else route 

780 wrong = routing.pop('method', None) 

781 if wrong is not None: 781 ↛ 782line 781 didn't jump to line 782 because the condition on line 781 was never true

782 _logger.warning("%s defined with invalid routing parameter 'method', assuming 'methods'", fname) 

783 routing['methods'] = wrong 

784 if routing.get('auth') == 'bearer': 

785 routing.setdefault('save_session', False) # stateless 

786 

787 @functools.wraps(endpoint) 

788 def route_wrapper(self, *args, **params): 

789 params_ok = filter_kwargs(endpoint, params) 

790 params_ko = set(params) - set(params_ok) 

791 if params_ko: 

792 _logger.warning("%s called ignoring args %s", fname, params_ko) 

793 

794 result = endpoint(self, *args, **params_ok) 

795 if routing['type'] == 'http': # _generate_routing_rules() ensures type is set 

796 return Response.load(result) 

797 return result 

798 

799 route_wrapper.original_routing = routing 

800 route_wrapper.original_endpoint = endpoint 

801 return route_wrapper 

802 return decorator 

803 

804 

805def _generate_routing_rules(modules, nodb_only, converters=None): 

806 """ 

807 Two-fold algorithm used to (1) determine which method in the 

808 controller inheritance tree should bind to what URL with respect to 

809 the list of installed modules and (2) merge the various @route 

810 arguments of said method with the @route arguments of the method it 

811 overrides. 

812 """ 

813 def is_valid(cls): 

814 """ Determine if the class is defined in an addon. """ 

815 path = cls.__module__.split('.') 

816 return path[:2] == ['odoo', 'addons'] and path[2] in modules 

817 

818 def get_leaf_classes(cls): 

819 """ 

820 Find the classes that have no child and that have ``cls`` as 

821 ancestor. 

822 """ 

823 result = [] 

824 for subcls in cls.__subclasses__(): 

825 if is_valid(subcls): 

826 result.extend(get_leaf_classes(subcls)) 

827 if not result and is_valid(cls): 

828 result.append(cls) 

829 return result 

830 

831 def build_controllers(): 

832 """ 

833 Create dummy controllers that inherit only from the controllers 

834 defined at the given ``modules`` (often system wide modules or 

835 installed modules). Modules in this context are Odoo addons. 

836 """ 

837 # Controllers defined outside of odoo addons are outside of the 

838 # controller inheritance/extension mechanism. 

839 yield from (ctrl() for ctrl in Controller.children_classes.get('', [])) 

840 

841 # Controllers defined inside of odoo addons can be extended in 

842 # other installed addons. Rebuild the class inheritance here. 

843 highest_controllers = [] 

844 for module in modules: 

845 highest_controllers.extend(Controller.children_classes.get(module, [])) 

846 

847 for top_ctrl in highest_controllers: 

848 leaf_controllers = list(unique(get_leaf_classes(top_ctrl))) 

849 

850 name = top_ctrl.__name__ 

851 if leaf_controllers != [top_ctrl]: 

852 name += ' (extended by %s)' % ', '.join( 

853 bot_ctrl.__name__ 

854 for bot_ctrl in leaf_controllers 

855 if bot_ctrl is not top_ctrl 

856 ) 

857 

858 Ctrl = type(name, tuple(reversed(leaf_controllers)), {}) 

859 yield Ctrl() 

860 

861 for ctrl in build_controllers(): 

862 for method_name, method in inspect.getmembers(ctrl, inspect.ismethod): 

863 

864 # Skip this method if it is not @route decorated anywhere in 

865 # the hierarchy 

866 def is_method_a_route(cls): 

867 return getattr(getattr(cls, method_name, None), 'original_routing', None) is not None 

868 if not any(map(is_method_a_route, type(ctrl).mro())): 

869 continue 

870 

871 merged_routing = { 

872 # 'type': 'http', # set below 

873 'auth': 'user', 

874 'methods': None, 

875 'routes': [], 

876 } 

877 

878 for cls in unique(reversed(type(ctrl).mro()[:-2])): # ancestors first 

879 if method_name not in cls.__dict__: 

880 continue 

881 submethod = getattr(cls, method_name) 

882 

883 if not hasattr(submethod, 'original_routing'): 

884 _logger.warning("The endpoint %s is not decorated by @route(), decorating it myself.", f'{cls.__module__}.{cls.__name__}.{method_name}') 

885 submethod = route()(submethod) 

886 

887 _check_and_complete_route_definition(cls, submethod, merged_routing) 

888 

889 merged_routing.update(submethod.original_routing) 

890 

891 if not merged_routing['routes']: 

892 _logger.warning("%s is a controller endpoint without any route, skipping.", f'{cls.__module__}.{cls.__name__}.{method_name}') 

893 continue 

894 

895 if nodb_only and merged_routing['auth'] != "none": 

896 continue 

897 

898 for url in merged_routing['routes']: 

899 # duplicates the function (partial) with a copy of the 

900 # original __dict__ (update_wrapper) to keep a reference 

901 # to `original_routing` and `original_endpoint`, assign 

902 # the merged routing ONLY on the duplicated function to 

903 # ensure method's immutability. 

904 endpoint = functools.partial(method) 

905 functools.update_wrapper(endpoint, method) 

906 endpoint.routing = merged_routing 

907 

908 yield (url, endpoint) 

909 

910 

911def _check_and_complete_route_definition(controller_cls, submethod, merged_routing): 

912 """Verify and complete the route definition. 

913 

914 * Ensure 'type' is defined on each method's own routing. 

915 * Ensure overrides don't change the routing type or the read/write mode 

916 

917 :param submethod: route method 

918 :param dict merged_routing: accumulated routing values 

919 """ 

920 default_type = submethod.original_routing.get('type', 'http') 

921 routing_type = merged_routing.setdefault('type', default_type) 

922 if submethod.original_routing.get('type') not in (None, routing_type): 

923 _logger.warning( 

924 "The endpoint %s changes the route type, using the original type: %r.", 

925 f'{controller_cls.__module__}.{controller_cls.__name__}.{submethod.__name__}', 

926 routing_type) 

927 submethod.original_routing['type'] = routing_type 

928 

929 default_auth = submethod.original_routing.get('auth', merged_routing['auth']) 

930 default_mode = submethod.original_routing.get('readonly', default_auth == 'none') 

931 parent_readonly = merged_routing.setdefault('readonly', default_mode) 

932 child_readonly = submethod.original_routing.get('readonly') 

933 if child_readonly not in (None, parent_readonly) and not callable(child_readonly): 

934 _logger.warning( 

935 "The endpoint %s made the route %s altough its parent was defined as %s. Setting the route read/write.", 

936 f'{controller_cls.__module__}.{controller_cls.__name__}.{submethod.__name__}', 

937 'readonly' if child_readonly else 'read/write', 

938 'readonly' if parent_readonly else 'read/write', 

939 ) 

940 submethod.original_routing['readonly'] = False 

941 

942 

943# ========================================================= 

944# Session 

945# ========================================================= 

946 

947_base64_urlsafe_re = re.compile(r'^[A-Za-z0-9_-]{84}$') 

948_session_identifier_re = re.compile(r'^[A-Za-z0-9_-]{%s}$' % STORED_SESSION_BYTES) 

949 

950 

951class FilesystemSessionStore(sessions.FilesystemSessionStore): 

952 """ Place where to load and save session objects. """ 

953 def get_session_filename(self, sid): 

954 # scatter sessions across 4096 (64^2) directories 

955 if not self.is_valid_key(sid): 

956 raise ValueError(f'Invalid session id {sid!r}') 

957 sha_dir = sid[:2] 

958 dirname = os.path.join(self.path, sha_dir) 

959 session_path = os.path.join(dirname, sid) 

960 return session_path 

961 

962 def save(self, session): 

963 session_path = self.get_session_filename(session.sid) 

964 dirname = os.path.dirname(session_path) 

965 if not os.path.isdir(dirname): 

966 with contextlib.suppress(OSError): 

967 os.mkdir(dirname, 0o0755) 

968 super().save(session) 

969 

970 def delete_old_sessions(self, session): 

971 if 'gc_previous_sessions' in session: 

972 if session['create_time'] + SESSION_DELETION_TIMER < time.time(): 

973 self.delete_from_identifiers([session.sid[:STORED_SESSION_BYTES]]) 

974 del session['gc_previous_sessions'] 

975 self.save(session) 

976 

977 def get(self, sid): 

978 # retro compatibility 

979 old_path = super().get_session_filename(sid) 

980 session_path = self.get_session_filename(sid) 

981 if os.path.isfile(old_path) and not os.path.isfile(session_path): 

982 dirname = os.path.dirname(session_path) 

983 if not os.path.isdir(dirname): 

984 with contextlib.suppress(OSError): 

985 os.mkdir(dirname, 0o0755) 

986 with contextlib.suppress(OSError): 

987 os.rename(old_path, session_path) 

988 session = super().get(sid) 

989 return session 

990 

991 def rotate(self, session, env, soft=False): 

992 # With a soft rotation, things like the CSRF token will still work. It's used for rotating 

993 # the session in a way that half the bytes remain to identify the user and the other half 

994 # to authenticate the user. Meanwhile with a hard rotation the entire session id is changed, 

995 # which is useful in cases such as logging the user out. 

996 if soft: 

997 # Multiple network requests can occur at the same time, all using the old session. 

998 # We don't want to create a new session for each request, it's better to reference the one already made. 

999 static = session.sid[:STORED_SESSION_BYTES] 

1000 recent_session = self.get(session.sid) 

1001 if 'next_sid' in recent_session: 

1002 # A new session has already been saved on disk by a concurrent request, 

1003 # the _save_session is going to simply use session.sid to set a new cookie. 

1004 session.sid = recent_session['next_sid'] 

1005 return 

1006 next_sid = static + self.generate_key()[STORED_SESSION_BYTES:] 

1007 session['next_sid'] = next_sid 

1008 session['deletion_time'] = time.time() + SESSION_DELETION_TIMER 

1009 self.save(session) 

1010 # Now prepare the new session 

1011 session['gc_previous_sessions'] = True 

1012 session.sid = next_sid 

1013 del session['deletion_time'] 

1014 del session['next_sid'] 

1015 else: 

1016 self.delete(session) 

1017 session.sid = self.generate_key() 

1018 if session.uid: 

1019 assert env, "saving this session requires an environment" 

1020 session.session_token = security.compute_session_token(session, env) 

1021 session.should_rotate = False 

1022 session['create_time'] = time.time() 

1023 self.save(session) 

1024 

1025 def vacuum(self, max_lifetime=SESSION_LIFETIME): 

1026 threshold = time.time() - max_lifetime 

1027 for fname in glob.iglob(os.path.join(root.session_store.path, '*', '*')): 

1028 path = os.path.join(root.session_store.path, fname) 

1029 with contextlib.suppress(OSError): 

1030 if os.path.getmtime(path) < threshold: 

1031 os.unlink(path) 

1032 

1033 def generate_key(self, salt=None): 

1034 # The generated key is case sensitive (base64) and the length is 84 chars. 

1035 # In the worst-case scenario, i.e. in an insensitive filesystem (NTFS for example) 

1036 # taking into account the proportion of characters in the pool and a length 

1037 # of 42 (stored part in the database), the entropy for the base64 generated key 

1038 # is 217.875 bits which is better than the 160 bits entropy of a hexadecimal key 

1039 # with a length of 40 (method ``generate_key`` of ``SessionStore``). 

1040 # The risk of collision is negligible in practice. 

1041 # Formulas: 

1042 # - L: length of generated word 

1043 # - p_char: probability of obtaining the character in the pool 

1044 # - n: size of the pool 

1045 # - k: number of generated word 

1046 # Entropy = - L * sum(p_char * log2(p_char)) 

1047 # Collision ~= (1 - exp((-k * (k - 1)) / (2 * (n**L)))) 

1048 key = str(time.time()).encode() + os.urandom(64) 

1049 hash_key = sha512(key).digest()[:-1] # prevent base64 padding 

1050 return base64.urlsafe_b64encode(hash_key).decode('utf-8') 

1051 

1052 def is_valid_key(self, key): 

1053 return _base64_urlsafe_re.match(key) is not None 

1054 

1055 def get_missing_session_identifiers(self, identifiers): 

1056 """ 

1057 :param identifiers: session identifiers whose file existence must be checked 

1058 identifiers are a part session sid (first 42 chars) 

1059 :type identifiers: iterable 

1060 :return: the identifiers which are not present on the filesystem 

1061 :rtype: set 

1062 """ 

1063 # There are a lot of session files. 

1064 # Use the param ``identifiers`` to select the necessary directories. 

1065 # In the worst case, we have 4096 directories (64^2). 

1066 identifiers = set(identifiers) 

1067 directories = { 

1068 os.path.normpath(os.path.join(self.path, identifier[:2])) 

1069 for identifier in identifiers 

1070 } 

1071 # Remove the identifiers for which a file is present on the filesystem. 

1072 for directory in directories: 

1073 with contextlib.suppress(OSError), os.scandir(directory) as session_files: 

1074 identifiers.difference_update(sf.name[:42] for sf in session_files) 

1075 return identifiers 

1076 

1077 def delete_from_identifiers(self, identifiers: list): 

1078 files_to_unlink = [] 

1079 for identifier in identifiers: 

1080 # Avoid to remove a session if it does not match an identifier. 

1081 # This prevent malicious user to delete sessions from a different 

1082 # database by specifying a custom ``res.device.log``. 

1083 if not _session_identifier_re.match(identifier): 

1084 raise ValueError("Identifier format incorrect, did you pass in a string instead of a list?") 

1085 normalized_path = os.path.normpath(os.path.join(self.path, identifier[:2], identifier + '*')) 

1086 if normalized_path.startswith(self.path): 

1087 files_to_unlink.extend(glob.glob(normalized_path)) 

1088 for fn in files_to_unlink: 

1089 with contextlib.suppress(OSError): 

1090 os.unlink(fn) 

1091 

1092 

1093class Session(collections.abc.MutableMapping): 

1094 """ Structure containing data persisted across requests. """ 

1095 __slots__ = ('can_save', '_Session__data', 'is_dirty', 'is_new', 

1096 'should_rotate', 'sid') 

1097 

1098 def __init__(self, data, sid, new=False): 

1099 self.can_save = True 

1100 self.__data = {} 

1101 self.update(data) 

1102 self.is_dirty = False 

1103 self.is_new = new 

1104 self.should_rotate = False 

1105 self.sid = sid 

1106 

1107 def __getitem__(self, item): 

1108 return self.__data[item] 

1109 

1110 def __setitem__(self, item, value): 

1111 value = json.loads(json.dumps(value)) 

1112 if item not in self.__data or self.__data[item] != value: 

1113 self.is_dirty = True 

1114 self.__data[item] = value 

1115 

1116 def __delitem__(self, item): 

1117 del self.__data[item] 

1118 self.is_dirty = True 

1119 

1120 def __len__(self): 

1121 return len(self.__data) 

1122 

1123 def __iter__(self): 

1124 return iter(self.__data) 

1125 

1126 def clear(self): 

1127 self.__data.clear() 

1128 self.is_dirty = True 

1129 

1130 # 

1131 # Session properties 

1132 # 

1133 @property 

1134 def uid(self): 

1135 return self.get('uid') 

1136 

1137 @uid.setter 

1138 def uid(self, uid): 

1139 self['uid'] = uid 

1140 

1141 @property 

1142 def db(self): 

1143 return self.get('db') 

1144 

1145 @db.setter 

1146 def db(self, db): 

1147 self['db'] = db 

1148 

1149 @property 

1150 def login(self): 

1151 return self.get('login') 

1152 

1153 @login.setter 

1154 def login(self, login): 

1155 self['login'] = login 

1156 

1157 @property 

1158 def context(self): 

1159 return self.get('context') 

1160 

1161 @context.setter 

1162 def context(self, context): 

1163 self['context'] = context 

1164 

1165 @property 

1166 def debug(self): 

1167 return self.get('debug') 

1168 

1169 @debug.setter 

1170 def debug(self, debug): 

1171 self['debug'] = debug 

1172 

1173 @property 

1174 def session_token(self): 

1175 return self.get('session_token') 

1176 

1177 @session_token.setter 

1178 def session_token(self, session_token): 

1179 self['session_token'] = session_token 

1180 

1181 # 

1182 # Session methods 

1183 # 

1184 def authenticate(self, env, credential): 

1185 """ 

1186 Authenticate the current user with the given db, login and 

1187 credential. If successful, store the authentication parameters in 

1188 the current session, unless multi-factor-auth (MFA) is 

1189 activated. In that case, that last part will be done by 

1190 :ref:`finalize`. 

1191 

1192 .. versionchanged:: saas-15.3 

1193 The current request is no longer updated using the user and 

1194 context of the session when the authentication is done using 

1195 a database different than request.db. It is up to the caller 

1196 to open a new cursor/registry/env on the given database. 

1197 """ 

1198 wsgienv = { 

1199 'interactive': True, 

1200 'base_location': request.httprequest.url_root.rstrip('/'), 

1201 'HTTP_HOST': request.httprequest.environ['HTTP_HOST'], 

1202 'REMOTE_ADDR': request.httprequest.environ['REMOTE_ADDR'], 

1203 } 

1204 env = env(user=None, su=False) 

1205 auth_info = env['res.users'].authenticate(credential, wsgienv) 

1206 pre_uid = auth_info['uid'] 

1207 

1208 self.uid = None 

1209 self['pre_login'] = credential['login'] 

1210 self['pre_uid'] = pre_uid 

1211 

1212 # if 2FA is disabled we finalize immediately 

1213 user = env['res.users'].browse(pre_uid) 

1214 if auth_info.get('mfa') == 'skip' or not user._mfa_url(): 

1215 self.finalize(env) 

1216 

1217 if request and request.session is self and request.db == env.registry.db_name: 

1218 request.env = env(user=self.uid, context=self.context) 

1219 request.update_context(lang=get_lang(request.env(user=pre_uid)).code) 

1220 

1221 return auth_info 

1222 

1223 def finalize(self, env): 

1224 """ 

1225 Finalizes a partial session, should be called on MFA validation 

1226 to convert a partial / pre-session into a logged-in one. 

1227 """ 

1228 login = self.pop('pre_login') 

1229 uid = self.pop('pre_uid') 

1230 

1231 env = env(user=uid) 

1232 user_context = dict(env['res.users'].context_get()) 

1233 

1234 self.should_rotate = True 

1235 self.update({ 

1236 'db': env.registry.db_name, 

1237 'login': login, 

1238 'uid': uid, 

1239 'context': user_context, 

1240 'session_token': env.user._compute_session_token(self.sid), 

1241 }) 

1242 

1243 def logout(self, keep_db=False): 

1244 db = self.db if keep_db else get_default_session()['db'] # None 

1245 debug = self.debug 

1246 self.clear() 

1247 self.update(get_default_session(), db=db, debug=debug) 

1248 self.context['lang'] = request.default_lang() if request else DEFAULT_LANG 

1249 self.should_rotate = True 

1250 

1251 if request and request.env: 

1252 request.env['ir.http']._post_logout() 

1253 

1254 def touch(self): 

1255 self.is_dirty = True 

1256 

1257 def update_trace(self, request): 

1258 """ 

1259 :return: dict if a device log has to be inserted, ``None`` otherwise 

1260 """ 

1261 if self.get('_trace_disable'): 

1262 # To avoid generating useless logs, e.g. for automated technical sessions, 

1263 # a session can be flagged with `_trace_disable`. This should never be done 

1264 # without a proper assessment of the consequences for auditability. 

1265 # Non-admin users have no direct or indirect way to set this flag, so it can't 

1266 # be abused by unprivileged users. Such sessions will of course still be 

1267 # subject to all other auditing mechanisms (server logs, web proxy logs, 

1268 # metadata tracking on modified records, etc.) 

1269 return 

1270 

1271 user_agent = request.httprequest.user_agent 

1272 platform = user_agent.platform 

1273 browser = user_agent.browser 

1274 ip_address = request.httprequest.remote_addr 

1275 now = int(datetime.now().timestamp()) 

1276 for trace in self['_trace']: 

1277 if trace['platform'] == platform and trace['browser'] == browser and trace['ip_address'] == ip_address: 

1278 # If the device logs are not up to date (i.e. not updated for one hour or more) 

1279 if bool(now - trace['last_activity'] >= 3600): 

1280 trace['last_activity'] = now 

1281 self.is_dirty = True 

1282 return trace 

1283 return 

1284 new_trace = { 

1285 'platform': platform, 

1286 'browser': browser, 

1287 'ip_address': ip_address, 

1288 'first_activity': now, 

1289 'last_activity': now 

1290 } 

1291 self['_trace'].append(new_trace) 

1292 self.is_dirty = True 

1293 return new_trace 

1294 

1295 def _delete_old_sessions(self): 

1296 root.session_store.delete_old_sessions(self) 

1297 

1298 

1299# ========================================================= 

1300# GeoIP 

1301# ========================================================= 

1302 

1303class GeoIP(collections.abc.Mapping): 

1304 """ 

1305 Ip Geolocalization utility, determine information such as the 

1306 country or the timezone of the user based on their IP Address. 

1307 

1308 The instances share the same API as `:class:`geoip2.models.City` 

1309 <https://geoip2.readthedocs.io/en/latest/#geoip2.models.City>`_. 

1310 

1311 When the IP couldn't be geolocalized (missing database, bad address) 

1312 then an empty object is returned. This empty object can be used like 

1313 a regular one with the exception that all info are set None. 

1314 

1315 :param str ip: The IP Address to geo-localize 

1316 

1317 .. note: 

1318 

1319 The geoip info the the current request are available at 

1320 :attr:`~odoo.http.request.geoip`. 

1321 

1322 .. code-block: 

1323 

1324 >>> GeoIP('127.0.0.1').country.iso_code 

1325 >>> odoo_ip = socket.gethostbyname('odoo.com') 

1326 >>> GeoIP(odoo_ip).country.iso_code 

1327 'FR' 

1328 """ 

1329 

1330 def __init__(self, ip): 

1331 self.ip = ip 

1332 

1333 @functools.cached_property 

1334 def _city_record(self): 

1335 try: 

1336 return root.geoip_city_db.city(self.ip) 

1337 except (OSError, maxminddb.InvalidDatabaseError): 

1338 return GEOIP_EMPTY_CITY 

1339 except geoip2.errors.AddressNotFoundError: 

1340 return GEOIP_EMPTY_CITY 

1341 

1342 @functools.cached_property 

1343 def _country_record(self): 

1344 if '_city_record' in vars(self): 

1345 # the City class inherits from the Country class and the 

1346 # city record is in cache already, save a geolocalization 

1347 return self._city_record 

1348 try: 

1349 return root.geoip_country_db.country(self.ip) 

1350 except (OSError, maxminddb.InvalidDatabaseError): 

1351 return self._city_record 

1352 except geoip2.errors.AddressNotFoundError: 

1353 return GEOIP_EMPTY_COUNTRY 

1354 

1355 @property 

1356 def country_name(self): 

1357 return self.country.name or self.continent.name 

1358 

1359 @property 

1360 def country_code(self): 

1361 return self.country.iso_code or self.continent.code 

1362 

1363 def __getattr__(self, attr): 

1364 # Be smart and determine whether the attribute exists on the 

1365 # country object or on the city object. 

1366 if hasattr(GEOIP_EMPTY_COUNTRY, attr): 

1367 return getattr(self._country_record, attr) 

1368 if hasattr(GEOIP_EMPTY_CITY, attr): 

1369 return getattr(self._city_record, attr) 

1370 raise AttributeError(f"{self} has no attribute {attr!r}") 

1371 

1372 def __bool__(self): 

1373 return self.country_name is not None 

1374 

1375 # Old dict API, undocumented for now, will be deprecated some day 

1376 def __getitem__(self, item): 

1377 if item == 'country_name': 

1378 return self.country_name 

1379 

1380 if item == 'country_code': 

1381 return self.country_code 

1382 

1383 if item == 'city': 

1384 return self.city.name 

1385 

1386 if item == 'latitude': 

1387 return self.location.latitude 

1388 

1389 if item == 'longitude': 

1390 return self.location.longitude 

1391 

1392 if item == 'region': 

1393 return self.subdivisions[0].iso_code if self.subdivisions else None 

1394 

1395 if item == 'time_zone': 

1396 return self.location.time_zone 

1397 

1398 raise KeyError(item) 

1399 

1400 def __iter__(self): 

1401 raise NotImplementedError("The dictionnary GeoIP API is deprecated.") 

1402 

1403 def __len__(self): 

1404 raise NotImplementedError("The dictionnary GeoIP API is deprecated.") 

1405 

1406 

1407# ========================================================= 

1408# Request and Response 

1409# ========================================================= 

1410 

1411# Thread local global request object 

1412_request_stack = werkzeug.local.LocalStack() 

1413request = _request_stack() 

1414 

1415@contextlib.contextmanager 

1416def borrow_request(): 

1417 """ Get the current request and unexpose it from the local stack. """ 

1418 req = _request_stack.pop() 

1419 try: 

1420 yield req 

1421 finally: 

1422 _request_stack.push(req) 

1423 

1424 

1425def make_request_wrap_methods(attr): 

1426 def getter(self): 

1427 return getattr(self._HTTPRequest__wrapped, attr) 

1428 

1429 def setter(self, value): 

1430 return setattr(self._HTTPRequest__wrapped, attr, value) 

1431 

1432 return getter, setter 

1433 

1434 

1435class HTTPRequest: 

1436 def __init__(self, environ): 

1437 httprequest = werkzeug.wrappers.Request(environ) 

1438 httprequest.user_agent_class = UserAgent # use vendored userAgent since it will be removed in 2.1 

1439 httprequest.parameter_storage_class = werkzeug.datastructures.ImmutableMultiDict 

1440 httprequest.max_content_length = DEFAULT_MAX_CONTENT_LENGTH 

1441 httprequest.max_form_memory_size = 10 * 1024 * 1024 # 10 MB 

1442 self._session_id__ = httprequest.cookies.get('session_id') 

1443 

1444 self.__wrapped = httprequest 

1445 self.__environ = self.__wrapped.environ 

1446 self.environ = self.headers.environ = { 

1447 key: value 

1448 for key, value in self.__environ.items() 

1449 if (not key.startswith(('werkzeug.', 'wsgi.', 'socket')) or key in ['wsgi.url_scheme', 'werkzeug.proxy_fix.orig']) 

1450 } 

1451 

1452 def __enter__(self): 

1453 return self 

1454 

1455 

1456HTTPREQUEST_ATTRIBUTES = [ 

1457 '__str__', '__repr__', '__exit__', 

1458 'accept_charsets', 'accept_languages', 'accept_mimetypes', 'access_route', 'args', 'authorization', 'base_url', 

1459 'charset', 'content_encoding', 'content_length', 'content_md5', 'content_type', 'cookies', 'data', 'date', 

1460 'encoding_errors', 'files', 'form', 'full_path', 'get_data', 'get_json', 'headers', 'host', 'host_url', 'if_match', 

1461 'if_modified_since', 'if_none_match', 'if_range', 'if_unmodified_since', 'is_json', 'is_secure', 'json', 

1462 'max_content_length', 'method', 'mimetype', 'mimetype_params', 'origin', 'path', 'pragma', 'query_string', 'range', 

1463 'referrer', 'remote_addr', 'remote_user', 'root_path', 'root_url', 'scheme', 'script_root', 'server', 'session', 

1464 'trusted_hosts', 'url', 'url_charset', 'url_root', 'user_agent', 'values', 

1465] 

1466for attr in HTTPREQUEST_ATTRIBUTES: 

1467 setattr(HTTPRequest, attr, property(*make_request_wrap_methods(attr))) 

1468 

1469 

1470class _Response(werkzeug.wrappers.Response): 

1471 """ 

1472 Outgoing HTTP response with body, status, headers and qweb support. 

1473 In addition to the :class:`werkzeug.wrappers.Response` parameters, 

1474 this class's constructor can take the following additional 

1475 parameters for QWeb Lazy Rendering. 

1476 

1477 :param str template: template to render 

1478 :param dict qcontext: Rendering context to use 

1479 :param int uid: User id to use for the ir.ui.view render call, 

1480 ``None`` to use the request's user (the default) 

1481 

1482 these attributes are available as parameters on the Response object 

1483 and can be altered at any time before rendering 

1484 

1485 Also exposes all the attributes and methods of 

1486 :class:`werkzeug.wrappers.Response`. 

1487 """ 

1488 default_mimetype = 'text/html' 

1489 

1490 def __init__(self, *args, **kw): 

1491 template = kw.pop('template', None) 

1492 qcontext = kw.pop('qcontext', None) 

1493 uid = kw.pop('uid', None) 

1494 super().__init__(*args, **kw) 

1495 self.set_default(template, qcontext, uid) 

1496 

1497 @classmethod 

1498 def load(cls, result, fname="<function>"): 

1499 """ 

1500 Convert the return value of an endpoint into a Response. 

1501 

1502 :param result: The endpoint return value to load the Response from. 

1503 :type result: Union[Response, werkzeug.wrappers.BaseResponse, 

1504 werkzeug.exceptions.HTTPException, str, bytes, NoneType] 

1505 :param str fname: The endpoint function name wherefrom the 

1506 result emanated, used for logging. 

1507 :returns: The created :class:`~odoo.http.Response`. 

1508 :rtype: Response 

1509 :raises TypeError: When ``result`` type is none of the above- 

1510 mentioned type. 

1511 """ 

1512 if isinstance(result, Response): 

1513 return result 

1514 

1515 if isinstance(result, werkzeug.exceptions.HTTPException): 

1516 _logger.warning("%s returns an HTTPException instead of raising it.", fname) 

1517 raise result 

1518 

1519 if isinstance(result, werkzeug.wrappers.Response): 

1520 response = cls.force_type(result) 

1521 response.set_default() 

1522 return response 

1523 

1524 if isinstance(result, (bytes, str, type(None))): 

1525 return Response(result) 

1526 

1527 raise TypeError(f"{fname} returns an invalid value: {result}") 

1528 

1529 def set_default(self, template=None, qcontext=None, uid=None): 

1530 self.template = template 

1531 self.qcontext = qcontext or dict() 

1532 self.qcontext['response_template'] = self.template 

1533 self.uid = uid 

1534 

1535 @property 

1536 def is_qweb(self): 

1537 return self.template is not None 

1538 

1539 def render(self): 

1540 """ Renders the Response's template, returns the result. """ 

1541 self.qcontext['request'] = request 

1542 return request.env["ir.ui.view"]._render_template(self.template, self.qcontext) 

1543 

1544 def flatten(self): 

1545 """ 

1546 Forces the rendering of the response's template, sets the result 

1547 as response body and unsets :attr:`.template` 

1548 """ 

1549 if self.template: 

1550 self.response.append(self.render()) 

1551 self.template = None 

1552 

1553 def set_cookie(self, key, value='', max_age=None, expires=-1, path='/', domain=None, secure=False, httponly=False, samesite=None, cookie_type='required'): 

1554 """ 

1555 The default expires in Werkzeug is None, which means a session cookie. 

1556 We want to continue to support the session cookie, but not by default. 

1557 Now the default is arbitrary 1 year. 

1558 So if you want a cookie of session, you have to explicitly pass expires=None. 

1559 """ 

1560 if expires == -1: # not provided value -> default value -> 1 year 

1561 expires = datetime.now() + timedelta(days=365) 

1562 

1563 if request.db and not request.env['ir.http']._is_allowed_cookie(cookie_type): 

1564 max_age = 0 

1565 super().set_cookie(key, value=value, max_age=max_age, expires=expires, path=path, domain=domain, secure=secure, httponly=httponly, samesite=samesite) 

1566 

1567 

1568class Headers(Proxy): 

1569 _wrapped__ = werkzeug.datastructures.Headers 

1570 

1571 __getitem__ = ProxyFunc() 

1572 __repr__ = ProxyFunc(str) 

1573 __setitem__ = ProxyFunc(None) 

1574 __str__ = ProxyFunc(str) 

1575 __contains__ = ProxyFunc(bool) 

1576 add = ProxyFunc(None) 

1577 add_header = ProxyFunc(None) 

1578 clear = ProxyFunc(None) 

1579 copy = ProxyFunc(lambda v: Headers(v)) # noqa: PLW0108 

1580 extend = ProxyFunc(None) 

1581 get = ProxyFunc() 

1582 get_all = ProxyFunc() 

1583 getlist = ProxyFunc() 

1584 items = ProxyFunc() 

1585 keys = ProxyFunc() 

1586 pop = ProxyFunc() 

1587 popitem = ProxyFunc() 

1588 remove = ProxyFunc(None) 

1589 set = ProxyFunc(None) 

1590 setdefault = ProxyFunc() 

1591 setlist = ProxyFunc(None) 

1592 setlistdefault = ProxyFunc() 

1593 to_wsgi_list = ProxyFunc() 

1594 update = ProxyFunc(None) 

1595 values = ProxyFunc() 

1596 

1597 

1598class ResponseCacheControl(Proxy): 

1599 _wrapped__ = werkzeug.datastructures.ResponseCacheControl 

1600 

1601 __getitem__ = ProxyFunc() 

1602 __setitem__ = ProxyFunc(None) 

1603 immutable = ProxyAttr(bool) 

1604 max_age = ProxyAttr(int) 

1605 must_revalidate = ProxyAttr(bool) 

1606 no_cache = ProxyAttr(bool) 

1607 no_store = ProxyAttr(bool) 

1608 no_transform = ProxyAttr(bool) 

1609 public = ProxyAttr(bool) 

1610 private = ProxyAttr(bool) 

1611 proxy_revalidate = ProxyAttr(bool) 

1612 s_maxage = ProxyAttr(int) 

1613 pop = ProxyFunc() 

1614 

1615 

1616class ResponseStream(Proxy): 

1617 _wrapped__ = werkzeug.wrappers.ResponseStream 

1618 

1619 write = ProxyFunc(int) 

1620 writelines = ProxyFunc(None) 

1621 tell = ProxyFunc(int) 

1622 

1623 

1624class Response(Proxy): 

1625 _wrapped__ = _Response 

1626 

1627 # werkzeug.wrappers.Response attributes 

1628 __call__ = ProxyFunc() 

1629 add_etag = ProxyFunc(None) 

1630 age = ProxyAttr() 

1631 autocorrect_location_header = ProxyAttr(bool) 

1632 cache_control = ProxyAttr(ResponseCacheControl) 

1633 call_on_close = ProxyFunc() 

1634 charset = ProxyAttr(str) 

1635 content_encoding = ProxyAttr(str) 

1636 content_length = ProxyAttr(int) 

1637 content_location = ProxyAttr(str) 

1638 content_md5 = ProxyAttr(str) 

1639 content_type = ProxyAttr(str) 

1640 data = ProxyAttr() 

1641 default_mimetype = ProxyAttr(str) 

1642 default_status = ProxyAttr(int) 

1643 delete_cookie = ProxyFunc(None) 

1644 direct_passthrough = ProxyAttr(bool) 

1645 expires = ProxyAttr() 

1646 force_type = ProxyFunc(lambda v: Response(v)) # noqa: PLW0108 

1647 freeze = ProxyFunc(None) 

1648 get_data = ProxyFunc() 

1649 get_etag = ProxyFunc() 

1650 get_json = ProxyFunc() 

1651 headers = ProxyAttr(Headers) 

1652 is_json = ProxyAttr(bool) 

1653 is_sequence = ProxyAttr(bool) 

1654 is_streamed = ProxyAttr(bool) 

1655 iter_encoded = ProxyFunc() 

1656 json = ProxyAttr() 

1657 last_modified = ProxyAttr() 

1658 location = ProxyAttr(str) 

1659 make_conditional = ProxyFunc(lambda v: Response(v)) # noqa: PLW0108 

1660 make_sequence = ProxyFunc(None) 

1661 max_cookie_size = ProxyAttr(int) 

1662 mimetype = ProxyAttr(str) 

1663 response = ProxyAttr() 

1664 retry_after = ProxyAttr() 

1665 set_cookie = ProxyFunc(None) 

1666 set_data = ProxyFunc(None) 

1667 set_etag = ProxyFunc(None) 

1668 status = ProxyAttr(str) 

1669 status_code = ProxyAttr(int) 

1670 stream = ProxyAttr(ResponseStream) 

1671 

1672 # odoo.http._response attributes 

1673 load = ProxyFunc() 

1674 set_default = ProxyFunc(None) 

1675 qcontext = ProxyAttr() 

1676 template = ProxyAttr(str) 

1677 is_qweb = ProxyAttr(bool) 

1678 render = ProxyFunc() 

1679 flatten = ProxyFunc(None) 

1680 

1681 def __init__(self, *args, **kwargs): 

1682 response = None 

1683 if len(args) == 1: 

1684 arg = args[0] 

1685 if isinstance(arg, Response): 

1686 response = arg._wrapped__ 

1687 elif isinstance(arg, _Response): 

1688 response = arg 

1689 elif isinstance(arg, werkzeug.wrappers.Response): 

1690 response = _Response.load(arg) 

1691 if response is None: 

1692 if isinstance(kwargs.get('headers'), Headers): 

1693 kwargs['headers'] = kwargs['headers']._wrapped__ 

1694 response = _Response(*args, **kwargs) 

1695 

1696 super().__init__(response) 

1697 if 'set_cookie' in response.__dict__: 

1698 self.__dict__['set_cookie'] = response.__dict__['set_cookie'] 

1699 

1700 

1701__wz_get_response = HTTPException.get_response 

1702 

1703 

1704def get_response(self, environ=None, scope=None): 

1705 return Response(__wz_get_response(self, environ, scope)) 

1706 

1707 

1708HTTPException.get_response = get_response 

1709 

1710 

1711werkzeug_abort = werkzeug.exceptions.abort 

1712 

1713 

1714def abort(status, *args, **kwargs): 

1715 if isinstance(status, Response): 

1716 status = status._wrapped__ 

1717 werkzeug_abort(status, *args, **kwargs) 

1718 

1719 

1720werkzeug.exceptions.abort = abort 

1721 

1722 

1723class FutureResponse: 

1724 """ 

1725 werkzeug.Response mock class that only serves as placeholder for 

1726 headers to be injected in the final response. 

1727 """ 

1728 # used by werkzeug.Response.set_cookie 

1729 charset = 'utf-8' 

1730 max_cookie_size = 4093 

1731 

1732 def __init__(self): 

1733 self.headers = werkzeug.datastructures.Headers() 

1734 

1735 @property 

1736 def _charset(self): 

1737 return self.charset 

1738 

1739 @functools.wraps(werkzeug.Response.set_cookie) 

1740 def set_cookie(self, key, value='', max_age=None, expires=-1, path='/', domain=None, secure=False, httponly=False, samesite=None, cookie_type='required'): 

1741 if expires == -1: # not forced value -> default value -> 1 year 

1742 expires = datetime.now() + timedelta(days=365) 

1743 

1744 if request.db and not request.env['ir.http']._is_allowed_cookie(cookie_type): 

1745 max_age = 0 

1746 werkzeug.Response.set_cookie(self, key, value=value, max_age=max_age, expires=expires, path=path, domain=domain, secure=secure, httponly=httponly, samesite=samesite) 

1747 

1748 

1749class Request: 

1750 """ 

1751 Wrapper around the incoming HTTP request with deserialized request 

1752 parameters, session utilities and request dispatching logic. 

1753 """ 

1754 

1755 def __init__(self, httprequest): 

1756 self.httprequest = httprequest 

1757 self.future_response = FutureResponse() 

1758 self.dispatcher = _dispatchers['http'](self) # until we match 

1759 #self.params = {} # set by the Dispatcher 

1760 

1761 self.geoip = GeoIP(httprequest.remote_addr) 

1762 self.registry = None 

1763 self.env = None 

1764 

1765 def _post_init(self): 

1766 self.session, self.db = self._get_session_and_dbname() 

1767 self._post_init = None 

1768 

1769 def _get_session_and_dbname(self): 

1770 sid = self.httprequest._session_id__ 

1771 if not sid or not root.session_store.is_valid_key(sid): 

1772 session = root.session_store.new() 

1773 else: 

1774 session = root.session_store.get(sid) 

1775 session.sid = sid # in case the session was not persisted 

1776 

1777 for key, val in get_default_session().items(): 

1778 session.setdefault(key, val) 

1779 if not session.context.get('lang'): 

1780 session.context['lang'] = self.default_lang() 

1781 

1782 dbname = None 

1783 host = self.httprequest.environ['HTTP_HOST'] 

1784 header_dbname = self.httprequest.headers.get('X-Odoo-Database') 

1785 if session.db and db_filter([session.db], host=host): 

1786 dbname = session.db 

1787 if header_dbname and header_dbname != dbname: 

1788 e = ("Cannot use both the session_id cookie and the " 

1789 "x-odoo-database header.") 

1790 raise werkzeug.exceptions.Forbidden(e) 

1791 elif header_dbname: 

1792 session.can_save = False # stateless 

1793 if db_filter([header_dbname], host=host): 

1794 dbname = header_dbname 

1795 else: 

1796 all_dbs = db_list(force=True, host=host) 

1797 if len(all_dbs) == 1: 

1798 dbname = all_dbs[0] # monodb 

1799 

1800 if session.db != dbname: 

1801 if session.db: 

1802 _logger.warning("Logged into database %r, but dbfilter rejects it; logging session out.", session.db) 

1803 session.logout(keep_db=False) 

1804 session.db = dbname 

1805 

1806 session.is_dirty = False 

1807 return session, dbname 

1808 

1809 # ===================================================== 

1810 # Getters and setters 

1811 # ===================================================== 

1812 def update_env(self, user=None, context=None, su=None): 

1813 """ Update the environment of the current request. 

1814 

1815 :param user: optional user/user id to change the current user 

1816 :type user: int or :class:`res.users record<~odoo.addons.base.models.res_users.ResUsers>` 

1817 :param dict context: optional context dictionary to change the current context 

1818 :param bool su: optional boolean to change the superuser mode 

1819 """ 

1820 cr = None # None is a sentinel, it keeps the same cursor 

1821 self.env = self.env(cr, user, context, su) 

1822 self.env.transaction.default_env = self.env 

1823 threading.current_thread().uid = self.env.uid 

1824 

1825 def update_context(self, **overrides): 

1826 """ 

1827 Override the environment context of the current request with the 

1828 values of ``overrides``. To replace the entire context, please 

1829 use :meth:`~update_env` instead. 

1830 """ 

1831 self.update_env(context=dict(self.env.context, **overrides)) 

1832 

1833 @property 

1834 def context(self): 

1835 warnings.warn("Since 19.0, use request.env.context directly", DeprecationWarning, stacklevel=2) 

1836 return self.env.context 

1837 

1838 @context.setter 

1839 def context(self, value): 

1840 raise NotImplementedError("Use request.update_context instead.") 

1841 

1842 @property 

1843 def uid(self): 

1844 warnings.warn("Since 19.0, use request.env.uid directly", DeprecationWarning, stacklevel=2) 

1845 return self.env.uid 

1846 

1847 @uid.setter 

1848 def uid(self, value): 

1849 raise NotImplementedError("Use request.update_env instead.") 

1850 

1851 @property 

1852 def cr(self): 

1853 warnings.warn("Since 19.0, use request.env.cr directly", DeprecationWarning, stacklevel=2) 

1854 return self.env.cr 

1855 

1856 @cr.setter 

1857 def cr(self, value): 

1858 if value is None: 

1859 raise NotImplementedError("Close the cursor instead.") 

1860 raise ValueError("You cannot replace the cursor attached to the current request.") 

1861 

1862 _cr = cr 

1863 

1864 @functools.cached_property 

1865 def best_lang(self): 

1866 lang = self.httprequest.accept_languages.best 

1867 if not lang: 

1868 return None 

1869 

1870 try: 

1871 code, territory, _, _ = babel.core.parse_locale(lang, sep='-') 

1872 if territory: 

1873 lang = f'{code}_{territory}' 

1874 else: 

1875 lang = babel.core.LOCALE_ALIASES[code] 

1876 return lang 

1877 except (ValueError, KeyError): 

1878 return None 

1879 

1880 @functools.cached_property 

1881 def cookies(self): 

1882 cookies = werkzeug.datastructures.MultiDict(self.httprequest.cookies) 

1883 if self.registry: 

1884 self.registry['ir.http']._sanitize_cookies(cookies) 

1885 return werkzeug.datastructures.ImmutableMultiDict(cookies) 

1886 

1887 # ===================================================== 

1888 # Helpers 

1889 # ===================================================== 

1890 def csrf_token(self, time_limit=None): 

1891 """ 

1892 Generates and returns a CSRF token for the current session 

1893 

1894 :param Optional[int] time_limit: the CSRF token should only be 

1895 valid for the specified duration (in second), by default 

1896 48h, ``None`` for the token to be valid as long as the 

1897 current user's session is. 

1898 :returns: ASCII token string 

1899 :rtype: str 

1900 """ 

1901 secret = self.env['ir.config_parameter'].sudo().get_param('database.secret') 

1902 if not secret: 

1903 raise ValueError("CSRF protection requires a configured database secret") 

1904 

1905 # if no `time_limit` => distant 1y expiry so max_ts acts as salt, e.g. vs BREACH 

1906 max_ts = int(time.time() + (time_limit or CSRF_TOKEN_SALT)) 

1907 msg = f'{self.session.sid[:STORED_SESSION_BYTES]}{max_ts}'.encode() 

1908 

1909 hm = hmac.new(secret.encode('ascii'), msg, hashlib.sha1).hexdigest() 

1910 return f'{hm}o{max_ts}' 

1911 

1912 def validate_csrf(self, csrf): 

1913 """ 

1914 Is the given csrf token valid ? 

1915 

1916 :param str csrf: The token to validate. 

1917 :returns: ``True`` when valid, ``False`` when not. 

1918 :rtype: bool 

1919 """ 

1920 if not csrf: 

1921 return False 

1922 

1923 secret = self.env['ir.config_parameter'].sudo().get_param('database.secret') 

1924 if not secret: 

1925 raise ValueError("CSRF protection requires a configured database secret") 

1926 

1927 hm, _, max_ts = csrf.rpartition('o') 

1928 msg = f'{self.session.sid[:STORED_SESSION_BYTES]}{max_ts}'.encode() 

1929 

1930 if max_ts: 

1931 try: 

1932 if int(max_ts) < int(time.time()): 

1933 return False 

1934 except ValueError: 

1935 return False 

1936 

1937 hm_expected = hmac.new(secret.encode('ascii'), msg, hashlib.sha1).hexdigest() 

1938 return consteq(hm, hm_expected) 

1939 

1940 def default_context(self): 

1941 return dict(get_default_session()['context'], lang=self.default_lang()) 

1942 

1943 def default_lang(self): 

1944 """Returns default user language according to request specification 

1945 

1946 :returns: Preferred language if specified or 'en_US' 

1947 :rtype: str 

1948 """ 

1949 return self.best_lang or DEFAULT_LANG 

1950 

1951 def get_http_params(self): 

1952 """ 

1953 Extract key=value pairs from the query string and the forms 

1954 present in the body (both application/x-www-form-urlencoded and 

1955 multipart/form-data). 

1956 

1957 :returns: The merged key-value pairs. 

1958 :rtype: dict 

1959 """ 

1960 params = { 

1961 **self.httprequest.args, 

1962 **self.httprequest.form, 

1963 **self.httprequest.files 

1964 } 

1965 return params 

1966 

1967 def get_json_data(self): 

1968 return json.loads(self.httprequest.get_data(as_text=True)) 

1969 

1970 def _get_profiler_context_manager(self): 

1971 """ 

1972 Get a profiler when the profiling is enabled and the requested 

1973 URL is profile-safe. Otherwise, get a context-manager that does 

1974 nothing. 

1975 """ 

1976 if self.session.get('profile_session') and self.db: 

1977 if self.session['profile_expiration'] < str(datetime.now()): 

1978 # avoid having session profiling for too long if user forgets to disable profiling 

1979 self.session['profile_session'] = None 

1980 _logger.warning("Profiling expiration reached, disabling profiling") 

1981 elif 'set_profiling' in self.httprequest.path: 

1982 _logger.debug("Profiling disabled on set_profiling route") 

1983 elif self.httprequest.path.startswith('/websocket'): 

1984 _logger.debug("Profiling disabled for websocket") 

1985 elif odoo.evented: 

1986 # only longpolling should be in a evented server, but this is an additional safety 

1987 _logger.debug("Profiling disabled for evented server") 

1988 else: 

1989 try: 

1990 return profiler.Profiler( 

1991 db=self.db, 

1992 description=self.httprequest.full_path, 

1993 profile_session=self.session['profile_session'], 

1994 collectors=self.session['profile_collectors'], 

1995 params=self.session['profile_params'], 

1996 )._get_cm_proxy() 

1997 except Exception: 

1998 _logger.exception("Failure during Profiler creation") 

1999 self.session['profile_session'] = None 

2000 

2001 return contextlib.nullcontext() 

2002 

2003 def _inject_future_response(self, response): 

2004 response.headers.extend(self.future_response.headers) 

2005 return response 

2006 

2007 def make_response(self, data, headers=None, cookies=None, status=200): 

2008 """ Helper for non-HTML responses, or HTML responses with custom 

2009 response headers or cookies. 

2010 

2011 While handlers can just return the HTML markup of a page they want to 

2012 send as a string if non-HTML data is returned they need to create a 

2013 complete response object, or the returned data will not be correctly 

2014 interpreted by the clients. 

2015 

2016 :param str data: response body 

2017 :param int status: http status code 

2018 :param headers: HTTP headers to set on the response 

2019 :type headers: ``[(name, value)]`` 

2020 :param collections.abc.Mapping cookies: cookies to set on the client 

2021 :returns: a response object. 

2022 :rtype: :class:`~odoo.http.Response` 

2023 """ 

2024 response = Response(data, status=status, headers=headers) 

2025 if cookies: 

2026 for k, v in cookies.items(): 

2027 response.set_cookie(k, v) 

2028 return response 

2029 

2030 def make_json_response(self, data, headers=None, cookies=None, status=200): 

2031 """ Helper for JSON responses, it json-serializes ``data`` and 

2032 sets the Content-Type header accordingly if none is provided. 

2033 

2034 :param data: the data that will be json-serialized into the response body 

2035 :param int status: http status code 

2036 :param List[(str, str)] headers: HTTP headers to set on the response 

2037 :param collections.abc.Mapping cookies: cookies to set on the client 

2038 :rtype: :class:`~odoo.http.Response` 

2039 """ 

2040 data = json.dumps(data, ensure_ascii=False, default=json_default) 

2041 

2042 headers = werkzeug.datastructures.Headers(headers) 

2043 headers['Content-Length'] = len(data) 

2044 if 'Content-Type' not in headers: 

2045 headers['Content-Type'] = 'application/json; charset=utf-8' 

2046 

2047 return self.make_response(data, headers.to_wsgi_list(), cookies, status) 

2048 

2049 def not_found(self, description=None): 

2050 """ Shortcut for a `HTTP 404 

2051 <http://tools.ietf.org/html/rfc7231#section-6.5.4>`_ (Not Found) 

2052 response 

2053 """ 

2054 return NotFound(description) 

2055 

2056 def redirect(self, location, code=303, local=True): 

2057 # compatibility, Werkzeug support URL as location 

2058 if isinstance(location, URL): 

2059 location = location.to_url() 

2060 if local: 

2061 location = '/' + url_parse(location).replace(scheme='', netloc='').to_url().lstrip('/\\') 

2062 if self.db: 

2063 return self.env['ir.http']._redirect(location, code) 

2064 return werkzeug.utils.redirect(location, code, Response=Response) 

2065 

2066 def redirect_query(self, location, query=None, code=303, local=True): 

2067 if query: 

2068 location += '?' + url_encode(query) 

2069 return self.redirect(location, code=code, local=local) 

2070 

2071 def render(self, template, qcontext=None, lazy=True, **kw): 

2072 """ Lazy render of a QWeb template. 

2073 

2074 The actual rendering of the given template will occur at then end of 

2075 the dispatching. Meanwhile, the template and/or qcontext can be 

2076 altered or even replaced by a static response. 

2077 

2078 :param str template: template to render 

2079 :param dict qcontext: Rendering context to use 

2080 :param bool lazy: whether the template rendering should be deferred 

2081 until the last possible moment 

2082 :param dict kw: forwarded to werkzeug's Response object 

2083 """ 

2084 response = Response(template=template, qcontext=qcontext, **kw) 

2085 if not lazy: 

2086 return response.render() 

2087 return response 

2088 

2089 def reroute(self, path, query_string=None): 

2090 """ 

2091 Rewrite the current request URL using the new path and query 

2092 string. This act as a light redirection, it does not return a 

2093 3xx responses to the browser but still change the current URL. 

2094 """ 

2095 # WSGI encoding dance https://peps.python.org/pep-3333/#unicode-issues 

2096 if isinstance(path, str): 

2097 path = path.encode('utf-8') 

2098 path = path.decode('latin1', 'replace') 

2099 

2100 if query_string is None: 

2101 query_string = request.httprequest.environ['QUERY_STRING'] 

2102 

2103 # Change the WSGI environment 

2104 environ = self.httprequest._HTTPRequest__environ.copy() 

2105 environ['PATH_INFO'] = path 

2106 environ['QUERY_STRING'] = query_string 

2107 environ['RAW_URI'] = f'{path}?{query_string}' 

2108 # REQUEST_URI left as-is so it still contains the original URI 

2109 

2110 # Create and expose a new request from the modified WSGI env 

2111 httprequest = HTTPRequest(environ) 

2112 threading.current_thread().url = httprequest.url 

2113 self.httprequest = httprequest 

2114 

2115 def _save_session(self, env=None): 

2116 """ 

2117 Save a modified session on disk. 

2118 

2119 :param env: an environment to compute the session token. 

2120 MUST be left ``None`` (in which case it uses the request's 

2121 env) UNLESS the database changed. 

2122 """ 

2123 sess = self.session 

2124 if env is None: 

2125 env = self.env 

2126 

2127 if not sess.can_save: 

2128 return 

2129 

2130 if sess.should_rotate: 

2131 root.session_store.rotate(sess, env) # it saves 

2132 elif ( 

2133 sess.uid 

2134 and time.time() >= sess['create_time'] + SESSION_ROTATION_INTERVAL 

2135 and request.httprequest.path not in SESSION_ROTATION_EXCLUDED_PATHS 

2136 ): 

2137 root.session_store.rotate(sess, env, True) 

2138 elif sess.is_dirty: 

2139 root.session_store.save(sess) 

2140 

2141 cookie_sid = self.cookies.get('session_id') 

2142 if sess.is_dirty or cookie_sid != sess.sid: 

2143 self.future_response.set_cookie( 

2144 'session_id', 

2145 sess.sid, 

2146 max_age=get_session_max_inactivity(env), 

2147 httponly=True 

2148 ) 

2149 

2150 def _set_request_dispatcher(self, rule): 

2151 routing = rule.endpoint.routing 

2152 dispatcher_cls = _dispatchers[routing['type']] 

2153 if (not is_cors_preflight(self, rule.endpoint) 

2154 and not dispatcher_cls.is_compatible_with(self)): 

2155 compatible_dispatchers = [ 

2156 disp.routing_type 

2157 for disp in _dispatchers.values() 

2158 if disp.is_compatible_with(self) 

2159 ] 

2160 e = (f"Request inferred type is compatible with {compatible_dispatchers} " 

2161 f"but {routing['routes'][0]!r} is type={routing['type']!r}.\n\n" 

2162 "Please verify the Content-Type request header and try again.") 

2163 # werkzeug doesn't let us add headers to UnsupportedMediaType 

2164 # so use the following (ugly) to still achieve what we want 

2165 res = UnsupportedMediaType(e).get_response() 

2166 res.headers['Accept'] = ', '.join(dispatcher_cls.mimetypes) 

2167 raise UnsupportedMediaType(response=res) 

2168 self.dispatcher = dispatcher_cls(self) 

2169 

2170 # ===================================================== 

2171 # Routing 

2172 # ===================================================== 

2173 def _serve_static(self): 

2174 """ Serve a static file from the file system. """ 

2175 module, _, path = self.httprequest.path[1:].partition('/static/') 

2176 try: 

2177 directory = root.static_path(module) 

2178 if not directory: 

2179 raise NotFound(f'Module "{module}" not found.\n') 

2180 filepath = werkzeug.security.safe_join(directory, path) 

2181 debug = ( 

2182 'assets' in self.session.debug and 

2183 ' wkhtmltopdf ' not in self.httprequest.user_agent.string 

2184 ) 

2185 res = Stream.from_path(filepath, public=True).get_response( 

2186 max_age=0 if debug else STATIC_CACHE, 

2187 content_security_policy=None, 

2188 ) 

2189 root.set_csp(res) 

2190 return res 

2191 except OSError: # cover both missing file and invalid permissions 

2192 raise NotFound(f'File "{path}" not found in module {module}.\n') 

2193 

2194 def _serve_nodb(self): 

2195 """ 

2196 Dispatch the request to its matching controller in a 

2197 database-free environment. 

2198 """ 

2199 try: 

2200 router = root.nodb_routing_map.bind_to_environ(self.httprequest.environ) 

2201 try: 

2202 rule, args = router.match(return_rule=True) 

2203 except NotFound as exc: 

2204 exc.response = Response(NOT_FOUND_NODB, status=exc.code, headers=[ 

2205 ('Content-Type', 'text/html; charset=utf-8'), 

2206 ]) 

2207 raise 

2208 self._set_request_dispatcher(rule) 

2209 self.dispatcher.pre_dispatch(rule, args) 

2210 response = self.dispatcher.dispatch(rule.endpoint, args) 

2211 self.dispatcher.post_dispatch(response) 

2212 return response 

2213 except HTTPException as exc: 

2214 if exc.code is not None: 

2215 raise 

2216 # Valid response returned via werkzeug.exceptions.abort 

2217 response = exc.get_response() 

2218 HttpDispatcher(self).post_dispatch(response) 

2219 return response 

2220 

2221 def _serve_db(self): 

2222 """ Load the ORM and use it to process the request. """ 

2223 # reuse the same cursor for building, checking the registry, for 

2224 # matching the controller endpoint and serving the data 

2225 cr = None 

2226 try: 

2227 # get the registry and cursor (RO) 

2228 try: 

2229 registry = Registry(self.db) 

2230 cr = registry.cursor(readonly=True) 

2231 self.registry = registry.check_signaling(cr) 

2232 except (AttributeError, psycopg2.OperationalError, psycopg2.ProgrammingError) as e: 

2233 raise RegistryError(f"Cannot get registry {self.db}") from e 

2234 threading.current_thread().dbname = self.registry.db_name 

2235 

2236 # find the controller endpoint to use 

2237 self.env = odoo.api.Environment(cr, self.session.uid, self.session.context) 

2238 try: 

2239 rule, args = self.registry['ir.http']._match(self.httprequest.path) 

2240 except NotFound as not_found_exc: 

2241 # no controller endpoint matched -> fallback or 404 

2242 serve_func = functools.partial(self._serve_ir_http_fallback, not_found_exc) 

2243 readonly = True 

2244 else: 

2245 # a controller endpoint matched -> dispatch it the request 

2246 self._set_request_dispatcher(rule) 

2247 serve_func = functools.partial(self._serve_ir_http, rule, args) 

2248 readonly = rule.endpoint.routing['readonly'] 

2249 if callable(readonly): 

2250 readonly = readonly(rule.endpoint.func.__self__, rule, args) 

2251 

2252 # keep on using the RO cursor when a readonly route matched, 

2253 # and for serve fallback 

2254 if readonly and cr.readonly: 

2255 threading.current_thread().cursor_mode = 'ro' 

2256 try: 

2257 return service_model.retrying(serve_func, env=self.env) 

2258 except psycopg2.errors.ReadOnlySqlTransaction as exc: 

2259 # although the controller is marked read-only, it 

2260 # attempted a write operation, try again using a 

2261 # read/write cursor 

2262 _logger.warning("%s, retrying with a read/write cursor", exc.args[0].rstrip(), exc_info=True) 

2263 threading.current_thread().cursor_mode = 'ro->rw' 

2264 except Exception as exc: # noqa: BLE001 

2265 raise self._update_served_exception(exc) 

2266 else: 

2267 threading.current_thread().cursor_mode = 'rw' 

2268 

2269 # we must use a RW cursor when a read/write route matched, or 

2270 # there was a ReadOnlySqlTransaction error 

2271 if cr.readonly: 

2272 cr.close() 

2273 cr = self.env.registry.cursor() 

2274 else: 

2275 # the cursor is already a RW cursor, start a new transaction 

2276 # that will avoid repeatable read serialization errors because 

2277 # check signaling is not done in `retrying` and that function 

2278 # would just succeed the second time 

2279 cr.rollback() 

2280 assert not cr.readonly 

2281 self.env = self.env(cr=cr) 

2282 try: 

2283 return service_model.retrying(serve_func, env=self.env) 

2284 except Exception as exc: # noqa: BLE001 

2285 raise self._update_served_exception(exc) 

2286 except HTTPException as exc: 

2287 if exc.code is not None: 

2288 raise 

2289 # Valid response returned via werkzeug.exceptions.abort 

2290 response = exc.get_response() 

2291 HttpDispatcher(self).post_dispatch(response) 

2292 return response 

2293 finally: 

2294 self.env = None 

2295 if cr is not None: 

2296 cr.close() 

2297 

2298 def _update_served_exception(self, exc): 

2299 if isinstance(exc, HTTPException) and exc.code is None: 

2300 return exc # bubble up to _serve_db 

2301 if ( 

2302 'werkzeug' in config['dev_mode'] 

2303 and self.dispatcher.routing_type != JsonRPCDispatcher.routing_type 

2304 ): 

2305 return exc # bubble up to werkzeug.debug.DebuggedApplication 

2306 if not hasattr(exc, 'error_response'): 

2307 if isinstance(exc, AccessDenied): 

2308 exc.suppress_traceback() 

2309 exc.error_response = self.registry['ir.http']._handle_error(exc) 

2310 return exc 

2311 

2312 def _serve_ir_http_fallback(self, not_found): 

2313 """ 

2314 Called when no controller match the request path. Delegate to 

2315 ``ir.http._serve_fallback`` to give modules the opportunity to 

2316 find an alternative way to serve the request. In case no module 

2317 provided a response, a generic 404 - Not Found page is returned. 

2318 """ 

2319 self.params = self.get_http_params() 

2320 self.registry['ir.http']._auth_method_public() 

2321 response = self.registry['ir.http']._serve_fallback() 

2322 if response: 

2323 self.registry['ir.http']._post_dispatch(response) 

2324 return response 

2325 

2326 no_fallback = NotFound() 

2327 no_fallback.__context__ = not_found # During handling of {not_found}, {no_fallback} occurred: 

2328 no_fallback.error_response = self.registry['ir.http']._handle_error(no_fallback) 

2329 raise no_fallback 

2330 

2331 def _serve_ir_http(self, rule, args): 

2332 """ 

2333 Called when a controller match the request path. Delegate to 

2334 ``ir.http`` to serve a response. 

2335 """ 

2336 self.registry['ir.http']._authenticate(rule.endpoint) 

2337 self.registry['ir.http']._pre_dispatch(rule, args) 

2338 response = self.dispatcher.dispatch(rule.endpoint, args) 

2339 self.registry['ir.http']._post_dispatch(response) 

2340 return response 

2341 

2342 

2343# ========================================================= 

2344# Core type-specialized dispatchers 

2345# ========================================================= 

2346 

2347_dispatchers = {} 

2348 

2349class Dispatcher(ABC): 

2350 routing_type: str 

2351 mimetypes: collections.abc.Collection[str] = () 

2352 

2353 @classmethod 

2354 def __init_subclass__(cls): 

2355 super().__init_subclass__() 

2356 _dispatchers[cls.routing_type] = cls 

2357 

2358 def __init__(self, request): 

2359 self.request = request 

2360 

2361 @classmethod 

2362 @abstractmethod 

2363 def is_compatible_with(cls, request): 

2364 """ 

2365 Determine if the current request is compatible with this 

2366 dispatcher. 

2367 """ 

2368 

2369 def pre_dispatch(self, rule, args): 

2370 """ 

2371 Prepare the system before dispatching the request to its 

2372 controller. This method is often overridden in ir.http to 

2373 extract some info from the request query-string or headers and 

2374 to save them in the session or in the context. 

2375 """ 

2376 routing = rule.endpoint.routing 

2377 self.request.session.can_save &= routing.get('save_session', True) 

2378 

2379 set_header = self.request.future_response.headers.set 

2380 cors = routing.get('cors') 

2381 if cors: 

2382 set_header('Access-Control-Allow-Origin', cors) 

2383 set_header('Access-Control-Allow-Methods', ( 

2384 'POST' if routing['type'] == JsonRPCDispatcher.routing_type 

2385 else ', '.join(routing['methods'] or ['GET', 'POST']) 

2386 )) 

2387 

2388 if cors and self.request.httprequest.method == 'OPTIONS': 

2389 set_header('Access-Control-Max-Age', CORS_MAX_AGE) 

2390 set_header('Access-Control-Allow-Headers', 

2391 'Origin, X-Requested-With, Content-Type, Accept, Authorization') 

2392 werkzeug.exceptions.abort(Response(status=204)) 

2393 

2394 if 'max_content_length' in routing: 

2395 max_content_length = routing['max_content_length'] 

2396 if callable(max_content_length): 

2397 max_content_length = max_content_length(rule.endpoint.func.__self__) 

2398 self.request.httprequest.max_content_length = max_content_length 

2399 

2400 @abstractmethod 

2401 def dispatch(self, endpoint, args): 

2402 """ 

2403 Extract the params from the request's body and call the 

2404 endpoint. While it is preferred to override ir.http._pre_dispatch 

2405 and ir.http._post_dispatch, this method can be override to have 

2406 a tight control over the dispatching. 

2407 """ 

2408 

2409 def post_dispatch(self, response): 

2410 """ 

2411 Manipulate the HTTP response to inject various headers, also 

2412 save the session when it is dirty. 

2413 """ 

2414 self.request._save_session() 

2415 self.request._inject_future_response(response) 

2416 root.set_csp(response) 

2417 

2418 @abstractmethod 

2419 def handle_error(self, exc: Exception) -> collections.abc.Callable: 

2420 """ 

2421 Transform the exception into a valid HTTP response. Called upon 

2422 any exception while serving a request. 

2423 """ 

2424 

2425 

2426class HttpDispatcher(Dispatcher): 

2427 routing_type = 'http' 

2428 

2429 mimetypes = ('application/x-www-form-urlencoded', 'multipart/form-data', '*/*') 

2430 

2431 @classmethod 

2432 def is_compatible_with(cls, request): 

2433 return True 

2434 

2435 def dispatch(self, endpoint, args): 

2436 """ 

2437 Perform http-related actions such as deserializing the request 

2438 body and query-string and checking cors/csrf while dispatching a 

2439 request to a ``type='http'`` route. 

2440 

2441 See :meth:`~odoo.http.Response.load` method for the compatible 

2442 endpoint return types. 

2443 """ 

2444 self.request.params = dict(self.request.get_http_params(), **args) 

2445 

2446 # Check for CSRF token for relevant requests 

2447 if self.request.httprequest.method not in SAFE_HTTP_METHODS and endpoint.routing.get('csrf', True): 

2448 if not self.request.db: 

2449 return self.request.redirect('/web/database/selector') 

2450 

2451 token = self.request.params.pop('csrf_token', None) 

2452 if not self.request.validate_csrf(token): 

2453 if token is not None: 

2454 _logger.warning("CSRF validation failed on path '%s'", self.request.httprequest.path) 

2455 else: 

2456 _logger.warning(MISSING_CSRF_WARNING, request.httprequest.path) 

2457 raise werkzeug.exceptions.BadRequest('Session expired (invalid CSRF token)') 

2458 

2459 if self.request.db: 

2460 return self.request.registry['ir.http']._dispatch(endpoint) 

2461 else: 

2462 return endpoint(**self.request.params) 

2463 

2464 def handle_error(self, exc: Exception) -> collections.abc.Callable: 

2465 """ 

2466 Handle any exception that occurred while dispatching a request 

2467 to a `type='http'` route. Also handle exceptions that occurred 

2468 when no route matched the request path, when no fallback page 

2469 could be delivered and that the request ``Content-Type`` was not 

2470 json. 

2471 

2472 :param Exception exc: the exception that occurred. 

2473 :returns: a WSGI application 

2474 """ 

2475 if isinstance(exc, SessionExpiredException): 

2476 session = self.request.session 

2477 was_connected = session.uid is not None 

2478 session.logout(keep_db=True) 

2479 response = self.request.redirect_query('/web/login', {'redirect': self.request.httprequest.full_path}) 

2480 if was_connected: 

2481 root.session_store.rotate(session, self.request.env) 

2482 response.set_cookie('session_id', session.sid, max_age=get_session_max_inactivity(self.request.env), httponly=True) 

2483 return response 

2484 

2485 if isinstance(exc, HTTPException): 

2486 return exc 

2487 

2488 if isinstance(exc, UserError): 

2489 try: 

2490 return werkzeug_default_exceptions[exc.http_status](exc.args[0]) 

2491 except (KeyError, AttributeError): 

2492 return UnprocessableEntity(exc.args[0]) 

2493 

2494 return InternalServerError() 

2495 

2496 

2497class JsonRPCDispatcher(Dispatcher): 

2498 routing_type = 'jsonrpc' 

2499 mimetypes = ('application/json', 'application/json-rpc') 

2500 

2501 def __init__(self, request): 

2502 super().__init__(request) 

2503 self.jsonrequest = {} 

2504 self.request_id = None 

2505 

2506 @classmethod 

2507 def is_compatible_with(cls, request): 

2508 return request.httprequest.mimetype in cls.mimetypes 

2509 

2510 def dispatch(self, endpoint, args): 

2511 """ 

2512 `JSON-RPC 2 <http://www.jsonrpc.org/specification>`_ over HTTP. 

2513 

2514 Our implementation differs from the specification on two points: 

2515 

2516 1. The ``method`` member of the JSON-RPC request payload is 

2517 ignored as the HTTP path is already used to route the request 

2518 to the controller. 

2519 2. We only support parameter structures by-name, i.e. the 

2520 ``params`` member of the JSON-RPC request payload MUST be a 

2521 JSON Object and not a JSON Array. 

2522 

2523 In addition, it is possible to pass a context that replaces 

2524 the session context via a special ``context`` argument that is 

2525 removed prior to calling the endpoint. 

2526 

2527 Successful request:: 

2528 

2529 --> {"jsonrpc": "2.0", "method": "call", "params": {"arg1": "val1" }, "id": null} 

2530 

2531 <-- {"jsonrpc": "2.0", "result": { "res1": "val1" }, "id": null} 

2532 

2533 Request producing a error:: 

2534 

2535 --> {"jsonrpc": "2.0", "method": "call", "params": {"arg1": "val1" }, "id": null} 

2536 

2537 <-- {"jsonrpc": "2.0", "error": {"code": 1, "message": "End user error message.", "data": {"code": "codestring", "debug": "traceback" } }, "id": null} 

2538 

2539 """ 

2540 try: 

2541 self.jsonrequest = self.request.get_json_data() 

2542 self.request_id = self.jsonrequest.get('id') 

2543 except ValueError: 

2544 # must use abort+Response to bypass handle_error 

2545 werkzeug.exceptions.abort(Response("Invalid JSON data", status=400)) 

2546 except AttributeError: 

2547 # must use abort+Response to bypass handle_error 

2548 werkzeug.exceptions.abort(Response("Invalid JSON-RPC data", status=400)) 

2549 

2550 self.request.params = dict(self.jsonrequest.get('params', {}), **args) 

2551 

2552 if self.request.db: 

2553 result = self.request.registry['ir.http']._dispatch(endpoint) 

2554 else: 

2555 result = endpoint(**self.request.params) 

2556 return self._response(result) 

2557 

2558 def handle_error(self, exc: Exception) -> collections.abc.Callable: 

2559 """ 

2560 Handle any exception that occurred while dispatching a request to 

2561 a `type='jsonrpc'` route. Also handle exceptions that occurred when 

2562 no route matched the request path, that no fallback page could 

2563 be delivered and that the request ``Content-Type`` was json. 

2564 

2565 :param exc: the exception that occurred. 

2566 :returns: a WSGI application 

2567 """ 

2568 error = { 

2569 'code': 0, # we don't care of this code 

2570 'message': "Odoo Server Error", 

2571 'data': serialize_exception(exc), 

2572 } 

2573 if isinstance(exc, NotFound): 

2574 error['code'] = 404 

2575 error['message'] = "404: Not Found" 

2576 elif isinstance(exc, SessionExpiredException): 

2577 error['code'] = 100 

2578 error['message'] = "Odoo Session Expired" 

2579 

2580 return self._response(error=error) 

2581 

2582 def _response(self, result=None, error=None): 

2583 response = {'jsonrpc': '2.0', 'id': self.request_id} 

2584 if error is not None: 

2585 response['error'] = error 

2586 if result is not None: 

2587 response['result'] = result 

2588 

2589 return self.request.make_json_response(response) 

2590 

2591 

2592class Json2Dispatcher(Dispatcher): 

2593 routing_type = 'json2' 

2594 mimetypes = ('application/json',) 

2595 

2596 def __init__(self, request): 

2597 super().__init__(request) 

2598 self.jsonrequest = None 

2599 

2600 @classmethod 

2601 def is_compatible_with(cls, request): 

2602 return request.httprequest.mimetype in cls.mimetypes or not request.httprequest.content_length 

2603 

2604 def dispatch(self, endpoint, args): 

2605 # "args" are the path parameters, "id" in /web/image/<id> 

2606 if self.request.httprequest.content_length: 

2607 try: 

2608 self.jsonrequest = self.request.get_json_data() 

2609 except ValueError as exc: 

2610 e = f"could not parse the body as json: {exc.args[0]}" 

2611 raise werkzeug.exceptions.BadRequest(e) from exc 

2612 try: 

2613 self.request.params = self.jsonrequest | args 

2614 except TypeError: 

2615 self.request.params = dict(args) # make a copy 

2616 

2617 if self.request.db: 

2618 result = self.request.registry['ir.http']._dispatch(endpoint) 

2619 else: 

2620 result = endpoint(**self.request.params) 

2621 if isinstance(result, Response): 

2622 return result 

2623 return self.request.make_json_response(result) 

2624 

2625 def handle_error(self, exc: Exception) -> collections.abc.Callable: 

2626 if isinstance(exc, HTTPException) and exc.response: 

2627 return exc.response 

2628 

2629 headers = None 

2630 if isinstance(exc, (UserError, SessionExpiredException)): 

2631 status = exc.http_status 

2632 body = serialize_exception(exc) 

2633 elif isinstance(exc, HTTPException): 

2634 status = exc.code 

2635 body = serialize_exception( 

2636 exc, 

2637 message=exc.description, 

2638 arguments=(exc.description, exc.code), 

2639 ) 

2640 # strip Content-Type but keep the remaining headers 

2641 ct, *headers = exc.get_headers() 

2642 assert ct == ('Content-Type', 'text/html; charset=utf-8') 

2643 else: 

2644 status = HTTPStatus.INTERNAL_SERVER_ERROR 

2645 body = serialize_exception(exc) 

2646 

2647 return self.request.make_json_response(body, headers=headers, status=status) 

2648 

2649 

2650# ========================================================= 

2651# WSGI Entry Point 

2652# ========================================================= 

2653 

2654class Application: 

2655 """ Odoo WSGI application """ 

2656 # See also: https://www.python.org/dev/peps/pep-3333 

2657 

2658 def initialize(self): 

2659 """ 

2660 Initialize the application. 

2661 

2662 This is to be called when setting up a WSGI application after 

2663 initializing the configuration values. 

2664 """ 

2665 module_manager.initialize_sys_path() 

2666 from odoo.service.server import load_server_wide_modules # noqa: PLC0415 

2667 load_server_wide_modules() 

2668 

2669 def static_path(self, module_name: str) -> str | None: 

2670 """ 

2671 Map module names to their absolute ``static`` path on the file 

2672 system. 

2673 """ 

2674 manifest = module_manager.Manifest.for_addon(module_name, display_warning=False) 

2675 return manifest.static_path if manifest is not None else None 

2676 

2677 def get_static_file(self, url, host=''): 

2678 """ 

2679 Get the full-path of the file if the url resolves to a local 

2680 static file, otherwise return None. 

2681 

2682 Without the second host parameters, ``url`` must be an absolute 

2683 path, others URLs are considered faulty. 

2684 

2685 With the second host parameters, ``url`` can also be a full URI 

2686 and the authority found in the URL (if any) is validated against 

2687 the given ``host``. 

2688 """ 

2689 

2690 netloc, path = urlparse(url)[1:3] 

2691 try: 

2692 path_netloc, module, static, resource = path.split('/', 3) 

2693 except ValueError: 

2694 return None 

2695 

2696 if ((netloc and netloc != host) or (path_netloc and path_netloc != host)): 

2697 return None 

2698 

2699 if not (static == 'static' and resource): 

2700 return None 

2701 

2702 static_path = self.static_path(module) 

2703 if not static_path: 

2704 return None 

2705 

2706 try: 

2707 return file_path(opj(static_path, resource)) 

2708 except FileNotFoundError: 

2709 return None 

2710 

2711 @functools.cached_property 

2712 def nodb_routing_map(self): 

2713 nodb_routing_map = werkzeug.routing.Map(strict_slashes=False, converters=None) 

2714 for url, endpoint in _generate_routing_rules([''] + config['server_wide_modules'], nodb_only=True): 

2715 routing = submap(endpoint.routing, ROUTING_KEYS) 

2716 if routing['methods'] is not None and 'OPTIONS' not in routing['methods']: 

2717 routing['methods'] = [*routing['methods'], 'OPTIONS'] 

2718 rule = werkzeug.routing.Rule(url, endpoint=endpoint, **routing) 

2719 rule.merge_slashes = False 

2720 nodb_routing_map.add(rule) 

2721 

2722 return nodb_routing_map 

2723 

2724 @functools.cached_property 

2725 def session_store(self): 

2726 path = odoo.tools.config.session_dir 

2727 _logger.debug('HTTP sessions stored in: %s', path) 

2728 return FilesystemSessionStore(path, session_class=Session, renew_missing=True) 

2729 

2730 def get_db_router(self, db): 

2731 if not db: 

2732 return self.nodb_routing_map 

2733 return request.env['ir.http'].routing_map() 

2734 

2735 @functools.cached_property 

2736 def geoip_city_db(self): 

2737 try: 

2738 return geoip2.database.Reader(config['geoip_city_db']) 

2739 except (OSError, maxminddb.InvalidDatabaseError): 

2740 _logger.debug( 

2741 "Couldn't load Geoip City file at %s. IP Resolver disabled.", 

2742 config['geoip_city_db'], exc_info=True 

2743 ) 

2744 raise 

2745 

2746 @functools.cached_property 

2747 def geoip_country_db(self): 

2748 try: 

2749 return geoip2.database.Reader(config['geoip_country_db']) 

2750 except (OSError, maxminddb.InvalidDatabaseError) as exc: 

2751 _logger.debug("Couldn't load Geoip Country file (%s). Fallbacks on Geoip City.", exc,) 

2752 raise 

2753 

2754 def set_csp(self, response): 

2755 headers = response.headers 

2756 headers['X-Content-Type-Options'] = 'nosniff' 

2757 

2758 if 'Content-Security-Policy' in headers: 

2759 return 

2760 

2761 if not headers.get('Content-Type', '').startswith('image/'): 

2762 return 

2763 

2764 headers['Content-Security-Policy'] = "default-src 'none'" 

2765 

2766 def __call__(self, environ, start_response): 

2767 """ 

2768 WSGI application entry point. 

2769 

2770 :param dict environ: container for CGI environment variables 

2771 such as the request HTTP headers, the source IP address and 

2772 the body as an io file. 

2773 :param callable start_response: function provided by the WSGI 

2774 server that this application must call in order to send the 

2775 HTTP response status line and the response headers. 

2776 """ 

2777 current_thread = threading.current_thread() 

2778 current_thread.query_count = 0 

2779 current_thread.query_time = 0 

2780 current_thread.perf_t0 = real_time() 

2781 current_thread.cursor_mode = None 

2782 if hasattr(current_thread, 'dbname'): 

2783 del current_thread.dbname 

2784 if hasattr(current_thread, 'uid'): 

2785 del current_thread.uid 

2786 thread_local.rpc_model_method = '' 

2787 

2788 if odoo.tools.config['proxy_mode'] and environ.get("HTTP_X_FORWARDED_HOST"): 

2789 # The ProxyFix middleware has a side effect of updating the 

2790 # environ, see https://github.com/pallets/werkzeug/pull/2184 

2791 def fake_app(environ, start_response): 

2792 return [] 

2793 def fake_start_response(status, headers): 

2794 return 

2795 ProxyFix(fake_app)(environ, fake_start_response) 

2796 

2797 with HTTPRequest(environ) as httprequest: 

2798 request = Request(httprequest) 

2799 _request_stack.push(request) 

2800 

2801 try: 

2802 request._post_init() 

2803 current_thread.url = httprequest.url 

2804 

2805 if self.get_static_file(httprequest.path): 

2806 response = request._serve_static() 

2807 elif request.db: 

2808 try: 

2809 with request._get_profiler_context_manager(): 

2810 response = request._serve_db() 

2811 except RegistryError as e: 

2812 _logger.warning("Database or registry unusable, trying without", exc_info=e.__cause__) 

2813 request.db = None 

2814 request.session.logout() 

2815 if (httprequest.path.startswith('/odoo/') 

2816 or httprequest.path in ( 

2817 '/odoo', '/web', '/web/login', '/test_http/ensure_db', 

2818 )): 

2819 # ensure_db() protected routes, remove ?db= from the query string 

2820 args_nodb = request.httprequest.args.copy() 

2821 args_nodb.pop('db', None) 

2822 request.reroute(httprequest.path, url_encode(args_nodb)) 

2823 response = request._serve_nodb() 

2824 else: 

2825 response = request._serve_nodb() 

2826 return response(environ, start_response) 

2827 

2828 except Exception as exc: 

2829 # Logs the error here so the traceback starts with ``__call__``. 

2830 if hasattr(exc, 'loglevel'): 

2831 _logger.log(exc.loglevel, exc, exc_info=getattr(exc, 'exc_info', None)) 

2832 elif isinstance(exc, HTTPException): 

2833 pass 

2834 elif isinstance(exc, SessionExpiredException): 

2835 _logger.info(exc) 

2836 elif isinstance(exc, AccessError): 

2837 _logger.warning(exc, exc_info='access' in config['dev_mode']) 

2838 elif isinstance(exc, UserError): 

2839 _logger.warning(exc) 

2840 else: 

2841 _logger.exception("Exception during request handling.") 

2842 

2843 # Ensure there is always a WSGI handler attached to the exception. 

2844 if not hasattr(exc, 'error_response'): 

2845 if isinstance(exc, AccessDenied): 

2846 exc.suppress_traceback() 

2847 exc.error_response = request.dispatcher.handle_error(exc) 

2848 

2849 return exc.error_response(environ, start_response) 

2850 

2851 finally: 

2852 _request_stack.pop() 

2853 

2854 

2855root = Application()