mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 05:32:05 +02:00
18.0 vanilla
This commit is contained in:
parent
d72e748793
commit
0a7ae8db93
337 changed files with 399651 additions and 232598 deletions
|
|
@ -12,37 +12,46 @@ Application developers mostly know this module thanks to the
|
|||
:func:`~odoo.http.route`: method decorator. Together they are used to
|
||||
register methods responsible of delivering web content to matching URLS.
|
||||
|
||||
Those two are only the tip of the iceberg, below is an ascii graph that
|
||||
Those two are only the tip of the iceberg, below is a call graph that
|
||||
shows the various processing layers each request passes through before
|
||||
ending at the @route decorated endpoint. Hopefully, this graph and the
|
||||
attached function descriptions will help you understand this module.
|
||||
ending at the @route decorated endpoint. Hopefully, this call graph and
|
||||
the attached function descriptions will help you understand this module.
|
||||
|
||||
Here be dragons:
|
||||
|
||||
Application.__call__
|
||||
+-> Request._serve_static
|
||||
|
|
||||
+-> Request._serve_nodb
|
||||
| -> App.nodb_routing_map.match
|
||||
| -> Dispatcher.pre_dispatch
|
||||
| -> Dispatcher.dispatch
|
||||
| -> route_wrapper
|
||||
| -> endpoint
|
||||
| -> Dispatcher.post_dispatch
|
||||
|
|
||||
+-> Request._serve_db
|
||||
-> model.retrying
|
||||
-> Request._serve_ir_http
|
||||
-> env['ir.http']._match
|
||||
-> env['ir.http']._authenticate
|
||||
-> env['ir.http']._pre_dispatch
|
||||
-> Dispatcher.pre_dispatch
|
||||
-> Dispatcher.dispatch
|
||||
-> env['ir.http']._dispatch
|
||||
-> route_wrapper
|
||||
-> endpoint
|
||||
-> env['ir.http']._post_dispatch
|
||||
-> Dispatcher.post_dispatch
|
||||
if path is like '/<module>/static/<path>':
|
||||
Request._serve_static
|
||||
|
||||
elif not request.db:
|
||||
Request._serve_nodb
|
||||
App.nodb_routing_map.match
|
||||
Dispatcher.pre_dispatch
|
||||
Dispatcher.dispatch
|
||||
route_wrapper
|
||||
endpoint
|
||||
Dispatcher.post_dispatch
|
||||
|
||||
else:
|
||||
Request._serve_db
|
||||
env['ir.http']._match
|
||||
|
||||
if not match:
|
||||
Request._transactioning
|
||||
model.retrying
|
||||
env['ir.http']._serve_fallback
|
||||
env['ir.http']._post_dispatch
|
||||
else:
|
||||
Request._transactioning
|
||||
model.retrying
|
||||
env['ir.http']._authenticate
|
||||
env['ir.http']._pre_dispatch
|
||||
Dispatcher.pre_dispatch
|
||||
Dispatcher.dispatch
|
||||
env['ir.http']._dispatch
|
||||
route_wrapper
|
||||
endpoint
|
||||
env['ir.http']._post_dispatch
|
||||
|
||||
Application.__call__
|
||||
WSGI entry point, it sanitizes the request, it wraps it in a werkzeug
|
||||
|
|
@ -66,21 +75,27 @@ Request._serve_nodb
|
|||
|
||||
Request._serve_db
|
||||
Handle all requests that are not static when it is possible to connect
|
||||
to a database. It opens a session and initializes the ORM before
|
||||
forwarding the request to ``retrying`` and ``_serve_ir_http``.
|
||||
to a database. It opens a registry on the database and then delegates
|
||||
most of the effort the the ``ir.http`` abstract model. This model acts
|
||||
as a module-aware middleware, its implementation in ``base`` is merely
|
||||
more than just delegating to Dispatcher.
|
||||
|
||||
service.model.retrying
|
||||
Protect against SQL serialisation errors (when two different
|
||||
transactions write on the same record), when such an error occurs this
|
||||
function resets the session and the environment then re-dispatches the
|
||||
request.
|
||||
Request._transactioning & service.model.retrying
|
||||
Manage the cursor, the environment and exceptions that occured while
|
||||
executing the underlying function. They recover from various
|
||||
exceptions such as serialization errors and writes in read-only
|
||||
transactions. They catches all other exceptions and attach a http
|
||||
response to them (e.g. 500 - Internal Server Error)
|
||||
|
||||
Request._serve_ir_http
|
||||
Delegate most of the effort to the ``ir.http`` abstract model which
|
||||
itself calls RequestDispatch back. ``ir.http`` grants modularity in
|
||||
the http stack. The notable difference with nodb is that there is an
|
||||
authentication layer and a mechanism to serve pages that are not
|
||||
accessible through controllers.
|
||||
ir.http._match
|
||||
Match the controller endpoint that correspond to the request path.
|
||||
Beware that there is an important override for portal and website
|
||||
inside of the ``http_routing`` module.
|
||||
|
||||
ir.http._serve_fallback
|
||||
Find alternative ways to serve a request when its path does not match
|
||||
any controller. The path could be matching an attachment URL, a blog
|
||||
page, etc.
|
||||
|
||||
ir.http._authenticate
|
||||
Ensure the user on the current environment fulfill the requirement of
|
||||
|
|
@ -100,6 +115,11 @@ ir.http._post_dispatch/Dispatcher.post_dispatch
|
|||
Post process the response returned by the controller endpoint. Used to
|
||||
inject various headers such as Content-Security-Policy.
|
||||
|
||||
ir.http._handle_error
|
||||
Not present in the call-graph, is called for un-managed exceptions (SE
|
||||
or RO) that occured inside of ``Request._transactioning``. It returns
|
||||
a http response that wraps the error that occured.
|
||||
|
||||
route_wrapper, closure of the http.route decorator
|
||||
Sanitize the request parameters, call the route endpoint and
|
||||
optionally coerce the endpoint result.
|
||||
|
|
@ -126,9 +146,9 @@ import re
|
|||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import warnings
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, timedelta
|
||||
from hashlib import sha512
|
||||
from io import BytesIO
|
||||
from os.path import join as opj
|
||||
from pathlib import Path
|
||||
|
|
@ -176,11 +196,11 @@ from .exceptions import UserError, AccessError, AccessDenied
|
|||
from .modules.module import get_manifest
|
||||
from .modules.registry import Registry
|
||||
from .service import security, model as service_model
|
||||
from .tools import (config, consteq, date_utils, file_path, parse_version,
|
||||
profiler, submap, unique, ustr,)
|
||||
from .tools.facade import Proxy, ProxyAttr, ProxyFunc
|
||||
from .tools import (config, consteq, file_path, get_lang, json_default,
|
||||
parse_version, profiler, unique, exception_to_unicode)
|
||||
from .tools.func import filter_kwargs, lazy_property
|
||||
from .tools.misc import pickle
|
||||
from .tools.misc import submap
|
||||
from .tools.facade import Proxy, ProxyAttr, ProxyFunc
|
||||
from .tools._vendor import sessions
|
||||
from .tools._vendor.useragents import UserAgent
|
||||
|
||||
|
|
@ -188,25 +208,6 @@ from .tools._vendor.useragents import UserAgent
|
|||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =========================================================
|
||||
# Lib fixes
|
||||
# =========================================================
|
||||
|
||||
# Add potentially missing (older ubuntu) font mime types
|
||||
mimetypes.add_type('application/font-woff', '.woff')
|
||||
mimetypes.add_type('application/vnd.ms-fontobject', '.eot')
|
||||
mimetypes.add_type('application/x-font-ttf', '.ttf')
|
||||
mimetypes.add_type('image/webp', '.webp')
|
||||
# Add potentially wrong (detected on windows) svg mime types
|
||||
mimetypes.add_type('image/svg+xml', '.svg')
|
||||
# this one can be present on windows with the value 'text/plain' which
|
||||
# breaks loading js files from an addon's static folder
|
||||
mimetypes.add_type('text/javascript', '.js')
|
||||
|
||||
# To remove when corrected in Babel
|
||||
babel.core.LOCALE_ALIASES['nb'] = 'nb_NO'
|
||||
|
||||
|
||||
# =========================================================
|
||||
# Const
|
||||
# =========================================================
|
||||
|
|
@ -232,6 +233,7 @@ def get_default_session():
|
|||
'login': None,
|
||||
'uid': None,
|
||||
'session_token': None,
|
||||
'_trace': [],
|
||||
}
|
||||
|
||||
DEFAULT_MAX_CONTENT_LENGTH = 128 * 1024 * 1024 # 128MiB
|
||||
|
|
@ -251,7 +253,7 @@ No CSRF validation token provided for path %r
|
|||
|
||||
Odoo URLs are CSRF-protected by default (when accessed with unsafe
|
||||
HTTP methods). See
|
||||
https://www.odoo.com/documentation/17.0/developer/reference/addons/http.html#csrf
|
||||
https://www.odoo.com/documentation/master/developer/reference/addons/http.html#csrf
|
||||
for more details.
|
||||
|
||||
* if this endpoint is accessed through Odoo via py-QWeb form, embed a CSRF
|
||||
|
|
@ -302,6 +304,10 @@ STATIC_CACHE_LONG = 60 * 60 * 24 * 365
|
|||
# Helpers
|
||||
# =========================================================
|
||||
|
||||
class RegistryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SessionExpiredException(Exception):
|
||||
pass
|
||||
|
||||
|
|
@ -341,6 +347,7 @@ def db_list(force=False, host=None):
|
|||
return []
|
||||
return db_filter(dbs, host)
|
||||
|
||||
|
||||
def db_filter(dbs, host=None):
|
||||
"""
|
||||
Return the subset of ``dbs`` that match the dbfilter or the dbname
|
||||
|
|
@ -405,6 +412,19 @@ def dispatch_rpc(service_name, method, params):
|
|||
return dispatch(method, params)
|
||||
|
||||
|
||||
def get_session_max_inactivity(env):
|
||||
if not env or env.cr._closed:
|
||||
return SESSION_LIFETIME
|
||||
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
|
||||
try:
|
||||
return int(ICP.get_param('sessions.max_inactivity_seconds', SESSION_LIFETIME))
|
||||
except ValueError:
|
||||
_logger.warning("Invalid value for 'sessions.max_inactivity_seconds', using default value.")
|
||||
return SESSION_LIFETIME
|
||||
|
||||
|
||||
def is_cors_preflight(request, endpoint):
|
||||
return request.httprequest.method == 'OPTIONS' and endpoint.routing.get('cors', False)
|
||||
|
||||
|
|
@ -416,7 +436,7 @@ def serialize_exception(exception):
|
|||
return {
|
||||
'name': f'{module}.{name}' if module else name,
|
||||
'debug': traceback.format_exc(),
|
||||
'message': ustr(exception),
|
||||
'message': exception_to_unicode(exception),
|
||||
'arguments': exception.args,
|
||||
'context': getattr(exception, 'context', {}),
|
||||
}
|
||||
|
|
@ -426,22 +446,6 @@ def serialize_exception(exception):
|
|||
# File Streaming
|
||||
# =========================================================
|
||||
|
||||
def send_file(filepath_or_fp, mimetype=None, as_attachment=False, filename=None, mtime=None,
|
||||
add_etags=True, cache_timeout=STATIC_CACHE, conditional=True):
|
||||
warnings.warn('odoo.http.send_file is deprecated, please use odoo.http.Stream instead.', DeprecationWarning, stacklevel=2)
|
||||
return _send_file(
|
||||
filepath_or_fp,
|
||||
request.httprequest.environ,
|
||||
mimetype=mimetype,
|
||||
as_attachment=as_attachment,
|
||||
download_name=filename,
|
||||
last_modified=mtime,
|
||||
etag=add_etags,
|
||||
max_age=cache_timeout,
|
||||
response_class=Response,
|
||||
conditional=conditional
|
||||
)
|
||||
|
||||
|
||||
class Stream:
|
||||
"""
|
||||
|
|
@ -450,9 +454,9 @@ class Stream:
|
|||
This utility is safe, cache-aware and uses the best available
|
||||
streaming strategy. Works best with the --x-sendfile cli option.
|
||||
|
||||
Create a Stream via one of the constructors: :meth:`~from_path`:,
|
||||
:meth:`~from_attachment`: or :meth:`~from_binary_field`:, generate
|
||||
the corresponding HTTP response object via :meth:`~get_response`:.
|
||||
Create a Stream via one of the constructors: :meth:`~from_path`:, or
|
||||
:meth:`~from_binary_field`:, generate the corresponding HTTP response
|
||||
object via :meth:`~get_response`:.
|
||||
|
||||
Instantiating a Stream object manually without using one of the
|
||||
dedicated constructors is discouraged.
|
||||
|
|
@ -504,55 +508,6 @@ class Stream:
|
|||
public=public,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_attachment(cls, attachment):
|
||||
""" Create a :class:`~Stream`: from an ir.attachment record. """
|
||||
attachment.ensure_one()
|
||||
|
||||
self = cls(
|
||||
mimetype=attachment.mimetype,
|
||||
download_name=attachment.name,
|
||||
etag=attachment.checksum,
|
||||
public=attachment.public,
|
||||
)
|
||||
|
||||
if attachment.store_fname:
|
||||
self.type = 'path'
|
||||
self.path = werkzeug.security.safe_join(
|
||||
os.path.abspath(config.filestore(request.db)),
|
||||
attachment.store_fname
|
||||
)
|
||||
stat = os.stat(self.path)
|
||||
self.last_modified = stat.st_mtime
|
||||
self.size = stat.st_size
|
||||
|
||||
elif attachment.db_datas:
|
||||
self.type = 'data'
|
||||
self.data = attachment.raw
|
||||
self.last_modified = attachment.write_date
|
||||
self.size = len(self.data)
|
||||
|
||||
elif attachment.url:
|
||||
# When the URL targets a file located in an addon, assume it
|
||||
# is a path to the resource. It saves an indirection and
|
||||
# stream the file right away.
|
||||
static_path = root.get_static_file(
|
||||
attachment.url,
|
||||
host=request.httprequest.environ.get('HTTP_HOST', '')
|
||||
)
|
||||
if static_path:
|
||||
self = cls.from_path(static_path, public=True)
|
||||
else:
|
||||
self.type = 'url'
|
||||
self.url = attachment.url
|
||||
|
||||
else:
|
||||
self.type = 'data'
|
||||
self.data = b''
|
||||
self.size = 0
|
||||
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def from_binary_field(cls, record, field_name):
|
||||
""" Create a :class:`~Stream`: from a binary field. """
|
||||
|
|
@ -601,7 +556,7 @@ class Stream:
|
|||
"""
|
||||
Create the corresponding :class:`~Response` for the current stream.
|
||||
|
||||
:param bool as_attachment: Indicate to the browser that it
|
||||
:param bool|None as_attachment: Indicate to the browser that it
|
||||
should offer to save the file instead of displaying it.
|
||||
:param bool|None immutable: Add the ``immutable`` directive to
|
||||
the ``Cache-Control`` response header, allowing intermediary
|
||||
|
|
@ -620,6 +575,10 @@ class Stream:
|
|||
assert getattr(self, self.type) is not None, "There is nothing to stream, missing {self.type!r} attribute."
|
||||
|
||||
if self.type == 'url':
|
||||
if self.max_age is not None:
|
||||
res = request.redirect(self.url, code=302, local=False)
|
||||
res.headers['Cache-Control'] = f'max-age={self.max_age}'
|
||||
return res
|
||||
return request.redirect(self.url, code=301, local=False)
|
||||
|
||||
if as_attachment is None:
|
||||
|
|
@ -747,6 +706,12 @@ def route(route=None, **routing):
|
|||
|
||||
* ``'user'``: The user must be authenticated and the current
|
||||
request will be executed using the rights of the user.
|
||||
* ``'bearer'``: The user is authenticated using an "Authorization"
|
||||
request header, using the Bearer scheme with an API token.
|
||||
The request will be executed with the permissions of the
|
||||
corresponding user. If the header is missing, the request
|
||||
must belong to an authentication session, as for the "user"
|
||||
authentication method.
|
||||
* ``'public'``: The user may or may not be authenticated. If he
|
||||
isn't, the current request will be executed using the shared
|
||||
Public user.
|
||||
|
|
@ -760,6 +725,9 @@ def route(route=None, **routing):
|
|||
:param bool csrf: Whether CSRF protection should be enabled for the
|
||||
route. Enabled by default for ``'http'``-type requests, disabled
|
||||
by default for ``'json'``-type requests.
|
||||
:param Union[bool, Callable[[registry, request], bool]] readonly:
|
||||
Whether this endpoint should open a cursor on a read-only
|
||||
replica instead of (by default) the primary read/write database.
|
||||
:param Callable[[Exception], Response] handle_params_access_error:
|
||||
Implement a custom behavior if an error occurred when retrieving the record
|
||||
from the URL parameters (access error or missing error).
|
||||
|
|
@ -793,6 +761,7 @@ def route(route=None, **routing):
|
|||
return route_wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def _generate_routing_rules(modules, nodb_only, converters=None):
|
||||
"""
|
||||
Two-fold algorithm used to (1) determine which method in the
|
||||
|
|
@ -864,7 +833,6 @@ def _generate_routing_rules(modules, nodb_only, converters=None):
|
|||
'auth': 'user',
|
||||
'methods': None,
|
||||
'routes': [],
|
||||
'readonly': False,
|
||||
}
|
||||
|
||||
for cls in unique(reversed(type(ctrl).mro()[:-2])): # ancestors first
|
||||
|
|
@ -904,10 +872,10 @@ def _check_and_complete_route_definition(controller_cls, submethod, merged_routi
|
|||
"""Verify and complete the route definition.
|
||||
|
||||
* Ensure 'type' is defined on each method's own routing.
|
||||
* also ensure overrides don't change the routing type.
|
||||
* Ensure overrides don't change the routing type or the read/write mode
|
||||
|
||||
:param submethod: route method
|
||||
:param dict merged_routing: accumulated routing values (defaults + submethod ancestor methods)
|
||||
:param dict merged_routing: accumulated routing values
|
||||
"""
|
||||
default_type = submethod.original_routing.get('type', 'http')
|
||||
routing_type = merged_routing.setdefault('type', default_type)
|
||||
|
|
@ -918,14 +886,32 @@ def _check_and_complete_route_definition(controller_cls, submethod, merged_routi
|
|||
routing_type)
|
||||
submethod.original_routing['type'] = routing_type
|
||||
|
||||
default_auth = submethod.original_routing.get('auth', merged_routing['auth'])
|
||||
default_mode = submethod.original_routing.get('readonly', default_auth == 'none')
|
||||
parent_readonly = merged_routing.setdefault('readonly', default_mode)
|
||||
child_readonly = submethod.original_routing.get('readonly')
|
||||
if child_readonly not in (None, parent_readonly) and not callable(child_readonly):
|
||||
_logger.warning(
|
||||
"The endpoint %s made the route %s altough its parent was defined as %s. Setting the route read/write.",
|
||||
f'{controller_cls.__module__}.{controller_cls.__name__}.{submethod.__name__}',
|
||||
'readonly' if child_readonly else 'read/write',
|
||||
'readonly' if parent_readonly else 'read/write',
|
||||
)
|
||||
submethod.original_routing['readonly'] = False
|
||||
|
||||
|
||||
# =========================================================
|
||||
# Session
|
||||
# =========================================================
|
||||
|
||||
_base64_urlsafe_re = re.compile(r'^[A-Za-z0-9_-]{84}$')
|
||||
_session_identifier_re = re.compile(r'^[A-Za-z0-9_-]{42}$')
|
||||
|
||||
|
||||
class FilesystemSessionStore(sessions.FilesystemSessionStore):
|
||||
""" Place where to load and save session objects. """
|
||||
def get_session_filename(self, sid):
|
||||
# scatter sessions across 256 directories
|
||||
# scatter sessions across 4096 (64^2) directories
|
||||
if not self.is_valid_key(sid):
|
||||
raise ValueError(f'Invalid session id {sid!r}')
|
||||
sha_dir = sid[:2]
|
||||
|
|
@ -970,6 +956,43 @@ class FilesystemSessionStore(sessions.FilesystemSessionStore):
|
|||
if os.path.getmtime(path) < threshold:
|
||||
os.unlink(path)
|
||||
|
||||
def generate_key(self, salt=None):
|
||||
# The generated key is case sensitive (base64) and the length is 84 chars.
|
||||
# In the worst-case scenario, i.e. in an insensitive filesystem (NTFS for example)
|
||||
# taking into account the proportion of characters in the pool and a length
|
||||
# of 42 (stored part in the database), the entropy for the base64 generated key
|
||||
# is 217.875 bits which is better than the 160 bits entropy of a hexadecimal key
|
||||
# with a length of 40 (method ``generate_key`` of ``SessionStore``).
|
||||
# The risk of collision is negligible in practice.
|
||||
# Formulas:
|
||||
# - L: length of generated word
|
||||
# - p_char: probability of obtaining the character in the pool
|
||||
# - n: size of the pool
|
||||
# - k: number of generated word
|
||||
# Entropy = - L * sum(p_char * log2(p_char))
|
||||
# Collision ~= (1 - exp((-k * (k - 1)) / (2 * (n**L))))
|
||||
key = str(time.time()).encode() + os.urandom(64)
|
||||
hash_key = sha512(key).digest()[:-1] # prevent base64 padding
|
||||
return base64.urlsafe_b64encode(hash_key).decode('utf-8')
|
||||
|
||||
def is_valid_key(self, key):
|
||||
return _base64_urlsafe_re.match(key) is not None
|
||||
|
||||
def delete_from_identifiers(self, identifiers):
|
||||
files_to_unlink = []
|
||||
for identifier in identifiers:
|
||||
# Avoid to remove a session if it does not match an identifier.
|
||||
# This prevent malicious user to delete sessions from a different
|
||||
# database by specifying a custom ``res.device.log``.
|
||||
if not _session_identifier_re.match(identifier):
|
||||
continue
|
||||
normalized_path = os.path.normpath(os.path.join(self.path, identifier[:2], identifier + '*'))
|
||||
if normalized_path.startswith(self.path):
|
||||
files_to_unlink.extend(glob.glob(normalized_path))
|
||||
for fn in files_to_unlink:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(fn)
|
||||
|
||||
|
||||
class Session(collections.abc.MutableMapping):
|
||||
""" Structure containing data persisted across requests. """
|
||||
|
|
@ -989,13 +1012,10 @@ class Session(collections.abc.MutableMapping):
|
|||
# MutableMapping implementation with DocDict-like extension
|
||||
#
|
||||
def __getitem__(self, item):
|
||||
if item == 'geoip':
|
||||
warnings.warn('request.session.geoip have been moved to request.geoip', DeprecationWarning)
|
||||
return request.geoip if request else {}
|
||||
return self.__data[item]
|
||||
|
||||
def __setitem__(self, item, value):
|
||||
value = pickle.loads(pickle.dumps(value))
|
||||
value = json.loads(json.dumps(value))
|
||||
if item not in self.__data or self.__data[item] != value:
|
||||
self.is_dirty = True
|
||||
self.__data[item] = value
|
||||
|
|
@ -1026,10 +1046,10 @@ class Session(collections.abc.MutableMapping):
|
|||
#
|
||||
# Session methods
|
||||
#
|
||||
def authenticate(self, dbname, login=None, password=None):
|
||||
def authenticate(self, dbname, credential):
|
||||
"""
|
||||
Authenticate the current user with the given db, login and
|
||||
password. If successful, store the authentication parameters in
|
||||
credential. If successful, store the authentication parameters in
|
||||
the current session, unless multi-factor-auth (MFA) is
|
||||
activated. In that case, that last part will be done by
|
||||
:ref:`finalize`.
|
||||
|
|
@ -1048,10 +1068,11 @@ class Session(collections.abc.MutableMapping):
|
|||
}
|
||||
|
||||
registry = Registry(dbname)
|
||||
pre_uid = registry['res.users'].authenticate(dbname, login, password, wsgienv)
|
||||
auth_info = registry['res.users'].authenticate(dbname, credential, wsgienv)
|
||||
pre_uid = auth_info['uid']
|
||||
|
||||
self.uid = None
|
||||
self.pre_login = login
|
||||
self.pre_login = credential['login']
|
||||
self.pre_uid = pre_uid
|
||||
|
||||
with registry.cursor() as cr:
|
||||
|
|
@ -1059,17 +1080,16 @@ class Session(collections.abc.MutableMapping):
|
|||
|
||||
# if 2FA is disabled we finalize immediately
|
||||
user = env['res.users'].browse(pre_uid)
|
||||
if not user._mfa_url():
|
||||
if auth_info.get('mfa') == 'skip' or not user._mfa_url():
|
||||
self.finalize(env)
|
||||
|
||||
if request and request.session is self and request.db == dbname:
|
||||
# Like update_env(user=request.session.uid) but works when uid is None
|
||||
request.env = odoo.api.Environment(request.env.cr, self.uid, self.context)
|
||||
request.update_context(**self.context)
|
||||
request.update_context(lang=get_lang(request.env(user=pre_uid)).code)
|
||||
# request env needs to be able to access the latest changes from the auth layers
|
||||
request.env.cr.commit()
|
||||
|
||||
return pre_uid
|
||||
return auth_info
|
||||
|
||||
def finalize(self, env):
|
||||
"""
|
||||
|
|
@ -1105,6 +1125,43 @@ class Session(collections.abc.MutableMapping):
|
|||
def touch(self):
|
||||
self.is_dirty = True
|
||||
|
||||
def update_trace(self, request):
|
||||
"""
|
||||
:return: dict if a device log has to be inserted, ``None`` otherwise
|
||||
"""
|
||||
if self._trace_disable:
|
||||
# To avoid generating useless logs, e.g. for automated technical sessions,
|
||||
# a session can be flagged with `_trace_disable`. This should never be done
|
||||
# without a proper assessment of the consequences for auditability.
|
||||
# Non-admin users have no direct or indirect way to set this flag, so it can't
|
||||
# be abused by unprivileged users. Such sessions will of course still be
|
||||
# subject to all other auditing mechanisms (server logs, web proxy logs,
|
||||
# metadata tracking on modified records, etc.)
|
||||
return
|
||||
|
||||
user_agent = request.httprequest.user_agent
|
||||
platform = user_agent.platform
|
||||
browser = user_agent.browser
|
||||
ip_address = request.httprequest.remote_addr
|
||||
now = int(datetime.now().timestamp())
|
||||
for trace in self._trace:
|
||||
if trace['platform'] == platform and trace['browser'] == browser and trace['ip_address'] == ip_address:
|
||||
# If the device logs are not up to date (i.e. not updated for one hour or more)
|
||||
if bool(now - trace['last_activity'] >= 3600):
|
||||
trace['last_activity'] = now
|
||||
self.is_dirty = True
|
||||
return trace
|
||||
return
|
||||
new_trace = {
|
||||
'platform': platform,
|
||||
'browser': browser,
|
||||
'ip_address': ip_address,
|
||||
'first_activity': now,
|
||||
'last_activity': now
|
||||
}
|
||||
self._trace.append(new_trace)
|
||||
self.is_dirty = True
|
||||
return new_trace
|
||||
|
||||
|
||||
# =========================================================
|
||||
|
|
@ -1214,6 +1271,7 @@ class GeoIP(collections.abc.Mapping):
|
|||
def __len__(self):
|
||||
raise NotImplementedError("The dictionnary GeoIP API is deprecated.")
|
||||
|
||||
|
||||
# =========================================================
|
||||
# Request and Response
|
||||
# =========================================================
|
||||
|
|
@ -1605,6 +1663,17 @@ class Request:
|
|||
session.is_dirty = False
|
||||
return session, dbname
|
||||
|
||||
def _open_registry(self):
|
||||
try:
|
||||
registry = Registry(self.db)
|
||||
# use a RW cursor! Sequence data is not replicated and would
|
||||
# be invalid if accessed on a readonly replica. Cfr task-4399456
|
||||
cr_readwrite = registry.cursor(readonly=False)
|
||||
registry = registry.check_signaling(cr_readwrite)
|
||||
except (AttributeError, psycopg2.OperationalError, psycopg2.ProgrammingError) as e:
|
||||
raise RegistryError(f"Cannot get registry {self.db}") from e
|
||||
return registry, cr_readwrite
|
||||
|
||||
# =====================================================
|
||||
# Getters and setters
|
||||
# =====================================================
|
||||
|
|
@ -1672,6 +1741,13 @@ class Request:
|
|||
except (ValueError, KeyError):
|
||||
return None
|
||||
|
||||
@lazy_property
|
||||
def cookies(self):
|
||||
cookies = werkzeug.datastructures.MultiDict(self.httprequest.cookies)
|
||||
if self.registry:
|
||||
self.registry['ir.http']._sanitize_cookies(cookies)
|
||||
return werkzeug.datastructures.ImmutableMultiDict(cookies)
|
||||
|
||||
# =====================================================
|
||||
# Helpers
|
||||
# =====================================================
|
||||
|
|
@ -1825,7 +1901,7 @@ class Request:
|
|||
:param collections.abc.Mapping cookies: cookies to set on the client
|
||||
:rtype: :class:`~odoo.http.Response`
|
||||
"""
|
||||
data = json.dumps(data, ensure_ascii=False, default=date_utils.json_default)
|
||||
data = json.dumps(data, ensure_ascii=False, default=json_default)
|
||||
|
||||
headers = werkzeug.datastructures.Headers(headers)
|
||||
headers['Content-Length'] = len(data)
|
||||
|
|
@ -1874,6 +1950,32 @@ class Request:
|
|||
return response.render()
|
||||
return response
|
||||
|
||||
def reroute(self, path, query_string=None):
|
||||
"""
|
||||
Rewrite the current request URL using the new path and query
|
||||
string. This act as a light redirection, it does not return a
|
||||
3xx responses to the browser but still change the current URL.
|
||||
"""
|
||||
# WSGI encoding dance https://peps.python.org/pep-3333/#unicode-issues
|
||||
if isinstance(path, str):
|
||||
path = path.encode('utf-8')
|
||||
path = path.decode('latin1', 'replace')
|
||||
|
||||
if query_string is None:
|
||||
query_string = request.httprequest.environ['QUERY_STRING']
|
||||
|
||||
# Change the WSGI environment
|
||||
environ = self.httprequest._HTTPRequest__environ.copy()
|
||||
environ['PATH_INFO'] = path
|
||||
environ['QUERY_STRING'] = query_string
|
||||
environ['RAW_URI'] = f'{path}?{query_string}'
|
||||
# REQUEST_URI left as-is so it still contains the original URI
|
||||
|
||||
# Create and expose a new request from the modified WSGI env
|
||||
httprequest = HTTPRequest(environ)
|
||||
threading.current_thread().url = httprequest.url
|
||||
self.httprequest = httprequest
|
||||
|
||||
def _save_session(self):
|
||||
""" Save a modified session on disk. """
|
||||
sess = self.session
|
||||
|
|
@ -1886,9 +1988,9 @@ class Request:
|
|||
elif sess.is_dirty:
|
||||
root.session_store.save(sess)
|
||||
|
||||
cookie_sid = self.httprequest.cookies.get('session_id')
|
||||
cookie_sid = self.cookies.get('session_id')
|
||||
if sess.is_dirty or cookie_sid != sess.sid:
|
||||
self.future_response.set_cookie('session_id', sess.sid, max_age=SESSION_LIFETIME, httponly=True)
|
||||
self.future_response.set_cookie('session_id', sess.sid, max_age=get_session_max_inactivity(self.env), httponly=True)
|
||||
|
||||
def _set_request_dispatcher(self, rule):
|
||||
routing = rule.endpoint.routing
|
||||
|
|
@ -1945,62 +2047,113 @@ class Request:
|
|||
Prepare the user session and load the ORM before forwarding the
|
||||
request to ``_serve_ir_http``.
|
||||
"""
|
||||
cr_readwrite = None
|
||||
rule = None
|
||||
args = None
|
||||
not_found = None
|
||||
|
||||
# reuse the same cursor for building+checking the registry and
|
||||
# for matching the controller endpoint
|
||||
try:
|
||||
self.registry = Registry(self.db).check_signaling()
|
||||
self.registry, cr_readwrite = self._open_registry()
|
||||
threading.current_thread().dbname = self.registry.db_name
|
||||
except (AttributeError, psycopg2.OperationalError, psycopg2.ProgrammingError):
|
||||
# psycopg2 error or attribute error while constructing
|
||||
# the registry. That means either
|
||||
# - the database probably does not exists anymore, or
|
||||
# - the database is corrupted, or
|
||||
# - the database version doesn't match the server version.
|
||||
# So remove the database from the cookie
|
||||
self.db = None
|
||||
self.session.db = None
|
||||
root.session_store.save(self.session)
|
||||
if request.httprequest.path == '/web':
|
||||
# Internal Server Error
|
||||
raise
|
||||
else:
|
||||
return self._serve_nodb()
|
||||
|
||||
with contextlib.closing(self.registry.cursor()) as cr:
|
||||
self.env = odoo.api.Environment(cr, self.session.uid, self.session.context)
|
||||
threading.current_thread().uid = self.env.uid
|
||||
self.env = odoo.api.Environment(cr_readwrite, self.session.uid, self.session.context)
|
||||
try:
|
||||
return service_model.retrying(self._serve_ir_http, self.env)
|
||||
except Exception as exc:
|
||||
if isinstance(exc, HTTPException) and exc.code is None:
|
||||
raise # bubble up to odoo.http.Application.__call__
|
||||
if not hasattr(exc, 'error_response'):
|
||||
exc.error_response = self.registry['ir.http']._handle_error(exc)
|
||||
raise
|
||||
rule, args = self.registry['ir.http']._match(self.httprequest.path)
|
||||
except NotFound as not_found_exc:
|
||||
not_found = not_found_exc
|
||||
finally:
|
||||
if cr_readwrite is not None:
|
||||
cr_readwrite.close()
|
||||
|
||||
def _serve_ir_http(self):
|
||||
"""
|
||||
Delegate most of the processing to the ir.http model that is
|
||||
extensible by applications.
|
||||
"""
|
||||
ir_http = self.registry['ir.http']
|
||||
|
||||
try:
|
||||
rule, args = ir_http._match(self.httprequest.path)
|
||||
except NotFound:
|
||||
self.params = self.get_http_params()
|
||||
response = ir_http._serve_fallback()
|
||||
if response:
|
||||
self.dispatcher.post_dispatch(response)
|
||||
return response
|
||||
raise
|
||||
if not_found:
|
||||
# no controller endpoint matched -> fallback or 404
|
||||
return self._transactioning(
|
||||
functools.partial(self._serve_ir_http_fallback, not_found),
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# a controller endpoint matched -> dispatch it the request
|
||||
self._set_request_dispatcher(rule)
|
||||
ir_http._authenticate(rule.endpoint)
|
||||
ir_http._pre_dispatch(rule, args)
|
||||
readonly = rule.endpoint.routing['readonly']
|
||||
if callable(readonly):
|
||||
readonly = readonly(rule.endpoint.func.__self__)
|
||||
return self._transactioning(
|
||||
functools.partial(self._serve_ir_http, rule, args),
|
||||
readonly=readonly,
|
||||
)
|
||||
|
||||
def _serve_ir_http_fallback(self, not_found):
|
||||
"""
|
||||
Called when no controller match the request path. Delegate to
|
||||
``ir.http._serve_fallback`` to give modules the opportunity to
|
||||
find an alternative way to serve the request. In case no module
|
||||
provided a response, a generic 404 - Not Found page is returned.
|
||||
"""
|
||||
self.params = self.get_http_params()
|
||||
response = self.registry['ir.http']._serve_fallback()
|
||||
if response:
|
||||
self.registry['ir.http']._post_dispatch(response)
|
||||
return response
|
||||
|
||||
no_fallback = NotFound()
|
||||
no_fallback.__context__ = not_found # During handling of {not_found}, {no_fallback} occurred:
|
||||
no_fallback.error_response = self.registry['ir.http']._handle_error(no_fallback)
|
||||
raise no_fallback
|
||||
|
||||
def _serve_ir_http(self, rule, args):
|
||||
"""
|
||||
Called when a controller match the request path. Delegate to
|
||||
``ir.http`` to serve a response.
|
||||
"""
|
||||
self.registry['ir.http']._authenticate(rule.endpoint)
|
||||
self.registry['ir.http']._pre_dispatch(rule, args)
|
||||
response = self.dispatcher.dispatch(rule.endpoint, args)
|
||||
# the registry can have been reniewed by dispatch
|
||||
self.registry['ir.http']._post_dispatch(response)
|
||||
return response
|
||||
|
||||
def _transactioning(self, func, readonly):
|
||||
"""
|
||||
Call ``func`` within a new SQL transaction.
|
||||
|
||||
If ``func`` performs a write query (insert/update/delete) on a
|
||||
read-only transaction, the transaction is rolled back, and
|
||||
``func`` is called again in a read-write transaction.
|
||||
|
||||
Other errors are handled by ``ir.http._handle_error`` within
|
||||
the same transaction.
|
||||
|
||||
Note: This function does not reset any state set on ``request``
|
||||
and ``request.env`` upon returning. Therefore, any recordset
|
||||
set on request during one transaction WILL NOT be usable inside
|
||||
the following transactions unless the recordset is reset with
|
||||
``with_env(request.env)``. This is especially a concern between
|
||||
``_match`` and other ``ir.http`` methods, as ``_match`` is
|
||||
called inside its own dedicated transaction.
|
||||
"""
|
||||
for readonly_cr in (True, False) if readonly else (False,):
|
||||
threading.current_thread().cursor_mode = (
|
||||
'ro' if readonly_cr
|
||||
else 'ro->rw' if readonly
|
||||
else 'rw'
|
||||
)
|
||||
|
||||
with contextlib.closing(self.registry.cursor(readonly=readonly_cr)) as cr:
|
||||
self.env = self.env(cr=cr)
|
||||
try:
|
||||
return service_model.retrying(func, env=self.env)
|
||||
except psycopg2.errors.ReadOnlySqlTransaction as exc:
|
||||
_logger.warning("%s, retrying with a read/write cursor", exc.args[0].rstrip(), exc_info=True)
|
||||
continue
|
||||
except Exception as exc:
|
||||
if isinstance(exc, HTTPException) and exc.code is None:
|
||||
raise # bubble up to odoo.http.Application.__call__
|
||||
if 'werkzeug' in config['dev_mode'] and self.dispatcher.routing_type != 'json':
|
||||
raise # bubble up to werkzeug.debug.DebuggedApplication
|
||||
if not hasattr(exc, 'error_response'):
|
||||
exc.error_response = self.registry['ir.http']._handle_error(exc)
|
||||
raise
|
||||
|
||||
|
||||
# =========================================================
|
||||
# Core type-specialized dispatchers
|
||||
|
|
@ -2053,7 +2206,10 @@ class Dispatcher(ABC):
|
|||
werkzeug.exceptions.abort(Response(status=204))
|
||||
|
||||
if 'max_content_length' in routing:
|
||||
self.request.httprequest.max_content_length = routing['max_content_length']
|
||||
max_content_length = routing['max_content_length']
|
||||
if callable(max_content_length):
|
||||
max_content_length = max_content_length(rule.endpoint.func.__self__)
|
||||
self.request.httprequest.max_content_length = max_content_length
|
||||
|
||||
@abstractmethod
|
||||
def dispatch(self, endpoint, args):
|
||||
|
|
@ -2135,7 +2291,7 @@ class HttpDispatcher(Dispatcher):
|
|||
response = self.request.redirect_query('/web/login', {'redirect': self.request.httprequest.full_path})
|
||||
if was_connected:
|
||||
root.session_store.rotate(session, self.request.env)
|
||||
response.set_cookie('session_id', session.sid, max_age=SESSION_LIFETIME, httponly=True)
|
||||
response.set_cookie('session_id', session.sid, max_age=get_session_max_inactivity(self.env), httponly=True)
|
||||
return response
|
||||
|
||||
return (exc if isinstance(exc, HTTPException)
|
||||
|
|
@ -2190,10 +2346,10 @@ class JsonRPCDispatcher(Dispatcher):
|
|||
try:
|
||||
self.jsonrequest = self.request.get_json_data()
|
||||
self.request_id = self.jsonrequest.get('id')
|
||||
except ValueError as exc:
|
||||
except ValueError:
|
||||
# must use abort+Response to bypass handle_error
|
||||
werkzeug.exceptions.abort(Response("Invalid JSON data", status=400))
|
||||
except AttributeError as exc:
|
||||
except AttributeError:
|
||||
# must use abort+Response to bypass handle_error
|
||||
werkzeug.exceptions.abort(Response("Invalid JSON-RPC data", status=400))
|
||||
|
||||
|
|
@ -2367,6 +2523,7 @@ class Application:
|
|||
current_thread.query_count = 0
|
||||
current_thread.query_time = 0
|
||||
current_thread.perf_t0 = time.time()
|
||||
current_thread.cursor_mode = None
|
||||
if hasattr(current_thread, 'dbname'):
|
||||
del current_thread.dbname
|
||||
if hasattr(current_thread, 'uid'):
|
||||
|
|
@ -2392,8 +2549,22 @@ class Application:
|
|||
if self.get_static_file(httprequest.path):
|
||||
response = request._serve_static()
|
||||
elif request.db:
|
||||
with request._get_profiler_context_manager():
|
||||
response = request._serve_db()
|
||||
try:
|
||||
with request._get_profiler_context_manager():
|
||||
response = request._serve_db()
|
||||
except RegistryError as e:
|
||||
_logger.warning("Database or registry unusable, trying without", exc_info=e.__cause__)
|
||||
request.db = None
|
||||
request.session.logout()
|
||||
if (httprequest.path.startswith('/odoo/')
|
||||
or httprequest.path in (
|
||||
'/odoo', '/web', '/web/login', '/test_http/ensure_db',
|
||||
)):
|
||||
# ensure_db() protected routes, remove ?db= from the query string
|
||||
args_nodb = request.httprequest.args.copy()
|
||||
args_nodb.pop('db', None)
|
||||
request.reroute(httprequest.path, url_encode(args_nodb))
|
||||
response = request._serve_nodb()
|
||||
else:
|
||||
response = request._serve_nodb()
|
||||
return response(environ, start_response)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue