diff --git a/addons/http_routing/models/ir_http.py b/addons/http_routing/models/ir_http.py index 6b2872f45b2..48937575482 100644 --- a/addons/http_routing/models/ir_http.py +++ b/addons/http_routing/models/ir_http.py @@ -1,13 +1,16 @@ # -*- coding: utf-8 -*- +import contextlib import logging import os import re import traceback +import threading import unicodedata import werkzeug.exceptions import werkzeug.routing import werkzeug.urls +from werkzeug.exceptions import HTTPException, NotFound # optional python-slugify import (https://github.com/un33k/python-slugify) try: @@ -16,7 +19,7 @@ except ImportError: slugify_lib = None import odoo -from odoo import api, models, registry, exceptions, tools, http +from odoo import api, models, exceptions, tools, http from odoo.addons.base.models import ir_http from odoo.addons.base.models.ir_http import RequestUID from odoo.addons.base.models.ir_qweb import QWebException @@ -254,6 +257,7 @@ class ModelConverter(ir_http.ModelConverter): _uid = RequestUID(value=value, match=matching, converter=self) record_id = int(matching.group(2)) env = api.Environment(request.cr, _uid, request.context) + if record_id < 0: # limited support for negative IDs due to our slug pattern, assume abs() if not found if not env[self.model].browse(record_id).exists(): @@ -289,7 +293,7 @@ class IrHttp(models.AbstractModel): IrHttpModel = request.env['ir.http'].sudo() modules = IrHttpModel.get_translation_frontend_modules() - user_context = request.session.get_context() if request.session.uid else {} + user_context = request.session.context if request.session.uid else {} lang = user_context.get('lang') translation_hash = request.env['ir.translation'].get_web_translations_hash(modules, lang) @@ -337,15 +341,184 @@ class IrHttp(models.AbstractModel): :param lang_code: the lang `code` (en_US) """ if not lang_code: - return False - short_match = False + return None + + lang_codes = cls._get_frontend_langs() + if lang_code in lang_codes: + return lang_code + short = lang_code.partition('_')[0] - for code in cls._get_frontend_langs(): - if code == lang_code: - return code - if not short_match and code.startswith(short): - short_match = code - return short_match + return next((code for code in lang_codes if code.startswith(short)), None) + + @classmethod + def _match(cls, path): + """ + Grant multilang support to URL matching by using http 3xx + redirections and URL rewrite. This method also grants various + attributes such as ``lang`` and ``is_frontend`` on the current + ``request`` object. + + 1/ Use the URL as-is when it matches a non-multilang compatible + endpoint. + + 2/ Use the URL as-is when the lang is not present in the URL and + that the default lang has been requested. + + 3/ Use the URL as-is saving the requested lang when the user is + a bot and that the lang is missing from the URL. + + 4/ Redirect the browser when the lang is missing from the URL + but another lang than the default one has been requested. The + requested lang is injected before the original path. + + 5) Use the url as-is when the lang is missing from the URL, that + another lang than the default one has been requested but that + it is forbidden to redirect (e.g. POST) + + 6/ Redirect the browser when the lang is present in the URL but + it is the default lang. The lang is removed from the original + URL. + + 7/ Redirect the browser when the lang present in the URL is an + alias of the preferred lang url code (e.g. fr_FR -> fr) + + 8/ Redirect the browser when the requested page is the homepage + but that there is a trailing slash. + + 9/ Rewrite the URL when the lang is present in the URL, that it + matches and that this lang is not the default one. The URL is + rewritten to remove the lang. + + Note: The "requested lang" is (in order) either (1) the lang in + the URL or (2) the lang in the ``frontend_lang`` request + cookie or (3) the lang in the context or (4) the default + lang of the website. + """ + + # The URL has been rewritten already + if hasattr(request, 'is_frontend'): + return super()._match(path) + + # See /1, match a non website endpoint + with contextlib.suppress(NotFound): + rule, args = super()._match(path) + routing = rule.endpoint.routing + request.is_frontend = routing.get('website', False) + request.is_frontend_multilang = request.is_frontend and routing.get('multilang', routing['type'] == 'http') + if not request.is_frontend: + return rule, args + + _, url_lang_str, *rest = path.split('/', 2) + path_no_lang = '/' + (rest[0] if rest else '') + allow_redirect = request.httprequest.method != 'POST' + + # There is no user on the environment yet but the following code + # requires one to set the lang on the request. Temporary grant + # the public user. Don't try it at home! + real_env = request.env + try: + request.registry['ir.http']._auth_method_public() # it calls update_env + nearest_url_lang = cls.get_nearest_lang(request.env['res.lang']._lang_get_code(url_lang_str)) + cookie_lang = cls.get_nearest_lang(request.httprequest.cookies.get('frontend_lang')) + context_lang = cls.get_nearest_lang(real_env.context.get('lang')) + default_lang = cls._get_default_lang() + request.lang = request.env['res.lang']._lang_get( + nearest_url_lang or cookie_lang or context_lang or default_lang._get_cached('code') + ) + request_url_code = request.lang._get_cached('url_code') + finally: + request.env = real_env + + if not nearest_url_lang: + url_lang_str = None + + # See /2, no lang in url and default website + if not url_lang_str and request.lang == default_lang: + _logger.debug("%r (lang: %r) no lang in url and default website, continue", path, request_url_code) + + # See /3, missing lang in url but user-agent is a bot + elif not url_lang_str and request.env['ir.http'].is_a_bot(): + _logger.debug("%r (lang: %r) missing lang in url but user-agent is a bot, continue", path, request_url_code) + request.lang = default_lang + + # See /4, no lang in url and should not redirect (e.g. POST), continue + elif not url_lang_str and not allow_redirect: + _logger.debug("%r (lang: %r) no lang in url and should not redirect (e.g. POST), continue", path, request_url_code) + + # See /5, missing lang in url, /home -> /fr/home + elif not url_lang_str: + _logger.debug("%r (lang: %r) missing lang in url, redirect", path, request_url_code) + redirect = request.redirect_query(f'/{request_url_code}{path}', request.httprequest.args) + redirect.set_cookie('frontend_lang', request.lang._get_cached('code')) + werkzeug.exceptions.abort(redirect) + + # See /6, default lang in url, /en/home -> /home + elif url_lang_str == default_lang.url_code and allow_redirect: + _logger.debug("%r (lang: %r) default lang in url, redirect", path, request_url_code) + redirect = request.redirect_query(path_no_lang, request.httprequest.args) + redirect.set_cookie('frontend_lang', default_lang._get_cached('code')) + werkzeug.exceptions.abort(redirect) + + # See /7, lang alias in url, /fr_FR/home -> /fr/home + elif url_lang_str != request_url_code and allow_redirect: + _logger.debug("%r (lang: %r) lang alias in url, redirect", path, request_url_code) + redirect = request.redirect_query(f'/{request_url_code}{path_no_lang}', request.httprequest.args, code=301) + redirect.set_cookie('frontend_lang', request.lang._get_cached('code')) + werkzeug.exceptions.abort(redirect) + + # See /8, homepage with trailing slash. /fr_BE/ -> /fr_BE + elif path == f'/{url_lang_str}/' and allow_redirect: + _logger.debug("%r (lang: %r) homepage with trailing slash, redirect", path, request_url_code) + redirect = request.redirect_query(path[:-1], request.httprequest.args, code=301) + redirect.set_cookie('frontend_lang', default_lang._get_cached('code')) + werkzeug.exceptions.abort(redirect) + + # See /9, valid lang in url + elif url_lang_str == request_url_code: + # Rewrite the URL to remove the lang + _logger.debug("%r (lang: %r) valid lang in url, rewrite url and continue", path, request_url_code) + cls.reroute(path_no_lang) + path = path_no_lang + + else: + _logger.warning("%r (lang: %r) couldn't not correctly route this frontend request, url used as-is.", path, request_url_code) + + # Re-match using rewritten route and really raise for 404 errors + try: + rule, args = super()._match(path) + routing = rule.endpoint.routing + request.is_frontend = routing.get('website', False) + request.is_frontend_multilang = request.is_frontend and routing.get('multilang', routing['type'] == 'http') + return rule, args + except NotFound: + # Use website to render a nice 404 Not Found html page + request.is_frontend = True + request.is_frontend_multilang = True + raise + + @classmethod + def reroute(cls, 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. + """ + if query_string is None: + query_string = request.httprequest.environ['QUERY_STRING'] + + # Change the WSGI environment + environ = request.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 = werkzeug.wrappers.Request(environ) + httprequest.parameter_storage_class = ( + werkzeug.datastructures.ImmutableOrderedMultiDict) + threading.current_thread().url = httprequest.url + request.httprequest = httprequest @classmethod def _geoip_setup_resolver(cls): @@ -366,191 +539,50 @@ class IrHttp(models.AbstractModel): record = odoo._geoip_resolver.resolve(request.httprequest.remote_addr) or {} request.session['geoip'] = record - @classmethod - def _add_dispatch_parameters(cls, func): - Lang = request.env['res.lang'] - # only called for is_frontend request - if request.routing_iteration == 1: - context = dict(request.context) - path = request.httprequest.path.split('/') - is_a_bot = request.env['ir.http'].is_a_bot() - - lang_codes = [code for code, *_ in Lang.get_available()] - nearest_lang = not func and cls.get_nearest_lang(Lang._lang_get_code(path[1])) - cook_lang = request.httprequest.cookies.get('frontend_lang') - cook_lang = cook_lang in lang_codes and cook_lang - - if nearest_lang: - lang = Lang._lang_get(nearest_lang) - else: - nearest_ctx_lg = not is_a_bot and cls.get_nearest_lang(request.env.context.get('lang')) - nearest_ctx_lg = nearest_ctx_lg in lang_codes and nearest_ctx_lg - preferred_lang = Lang._lang_get(cook_lang or nearest_ctx_lg) - lang = preferred_lang or cls._get_default_lang() - - request.lang = lang - context['lang'] = lang._get_cached('code') - - # bind modified context - request.context = context @classmethod - def _dispatch(cls): - """ Before executing the endpoint method, add website params on request, such as - - current website (record) - - multilang support (set on cookies) - - geoip dict data are added in the session - Then follow the parent dispatching. - Reminder : Do not use `request.env` before authentication phase, otherwise the env - set on request will be created with uid=None (and it is a lazy property) - """ - request.routing_iteration = getattr(request, 'routing_iteration', 0) + 1 - - func = None - routing_error = None - - # handle // in url - if request.httprequest.method == 'GET' and '//' in request.httprequest.path: - new_url = request.httprequest.path.replace('//', '/') + '?' + request.httprequest.query_string.decode('utf-8') - return request.redirect(new_url, code=301) - - # locate the controller method - try: - rule, arguments = cls._match(request.httprequest.path) - func = rule.endpoint - request.is_frontend = func.routing.get('website', False) - except werkzeug.exceptions.NotFound as e: - # either we have a language prefixed route, either a real 404 - # in all cases, website processes them exept if second element is static - # Checking static will avoid to generate an expensive 404 web page since - # most of the time the browser is loading and inexisting assets or image. A standard 404 is enough. - # Earlier check would be difficult since we don't want to break data modules - path_components = request.httprequest.path.split('/') - request.is_frontend = len(path_components) < 3 or path_components[2] != 'static' or '.' not in path_components[-1] - routing_error = e - - request.is_frontend_multilang = not func or (func and request.is_frontend and func.routing.get('multilang', func.routing['type'] == 'http')) - - # check authentication level - try: - if func: - cls._authenticate(func) - elif request.uid is None and request.is_frontend: - cls._auth_method_public() - except Exception as e: - return cls._handle_exception(e) + def _pre_dispatch(cls, rule, args): + super()._pre_dispatch(rule, args) cls._geoip_setup_resolver() cls._geoip_resolve() - # For website routes (only), add website params on `request` if request.is_frontend: - cls._add_dispatch_parameters(func) + cls._frontend_pre_dispatch() - path = request.httprequest.path.split('/') - default_lg_id = cls._get_default_lang() - if request.routing_iteration == 1: - is_a_bot = request.env['ir.http'].is_a_bot() - nearest_lang = not func and cls.get_nearest_lang(request.env['res.lang']._lang_get_code(path[1])) - url_lg = nearest_lang and path[1] + # update the context of "" args + for key, val in list(args.items()): + if isinstance(val, models.BaseModel): + args[key] = val.with_context(request.context) - # The default lang should never be in the URL, and a wrong lang - # should never be in the URL. - wrong_url_lg = url_lg and (url_lg != request.lang.url_code or url_lg == default_lg_id.url_code) - # The lang is missing from the URL if multi lang is enabled for - # the route and the current lang is not the default lang. - # POST requests are excluded from this condition. - missing_url_lg = not url_lg and request.is_frontend_multilang and request.lang != default_lg_id and request.httprequest.method != 'POST' - # Bots should never be redirected when the lang is missing - # because it is the only way for them to index the default lang. - if wrong_url_lg or (missing_url_lg and not is_a_bot): - if url_lg: - path.pop(1) - if request.lang != default_lg_id: - path.insert(1, request.lang.url_code) - path = '/'.join(path) or '/' - routing_error = None - redirect = request.redirect(path + '?' + request.httprequest.query_string.decode('utf-8')) - redirect.set_cookie('frontend_lang', request.lang.code) - return redirect - elif url_lg: - request.uid = None - if request.httprequest.path == '/%s/' % url_lg: - # special case for homepage controller, mimick `_postprocess_args()` redirect - path = request.httprequest.path[:-1] - if request.httprequest.query_string: - path += '?' + request.httprequest.query_string.decode('utf-8') - return request.redirect(path, code=301) - path.pop(1) - routing_error = None - return cls.reroute('/'.join(path) or '/') - elif missing_url_lg and is_a_bot: - # Ensure that if the URL without lang is not redirected, the - # current lang is indeed the default lang, because it is the - # lang that bots should index in that case. - request.lang = default_lg_id - request.context = dict(request.context, lang=default_lg_id.code) - - if request.lang == default_lg_id: - context = dict(request.context) - context['edit_translations'] = False - request.context = context - - if routing_error: - return cls._handle_exception(routing_error) - - # removed cache for auth public - result = super(IrHttp, cls)._dispatch() - - cook_lang = request.httprequest.cookies.get('frontend_lang') - if request.is_frontend and cook_lang != request.lang.code and hasattr(result, 'set_cookie'): - result.set_cookie('frontend_lang', request.lang.code) - - return result + if request.is_frontend_multilang: + # A product with id 1 and named 'egg' is accessible via a + # frontend multilang enpoint 'foo' at the URL '/foo/1'. + # The preferred URL to access the product (and to generate + # URLs pointing it) should instead be the sluggified URL + # '/foo/egg-1'. This code is responsible of redirecting the + # browser from '/foo/1' to '/foo/egg-1', or '/fr/foo/1' to + # '/fr/foo/oeuf-1'. While it is nice (for humans) to have a + # pretty URL, the real reason of this redirection is SEO. + if request.httprequest.method in ('GET', 'HEAD'): + try: + _, path = rule.build(args) + except odoo.exceptions.MissingError: + raise werkzeug.exceptions.NotFound() + assert path is not None + generated_path = werkzeug.urls.url_unquote_plus(path) + current_path = werkzeug.urls.url_unquote_plus(request.httprequest.path) + if generated_path != current_path: + if request.lang != cls._get_default_lang(): + path = f'/{request.lang.url_code}{path}' + redirect = request.redirect_query(path, request.httprequest.args, code=301) + werkzeug.exceptions.abort(redirect) @classmethod - def _redirect(cls, location, code=303): - if request and request.db and getattr(request, 'is_frontend', False): - location = url_for(location) - return super()._redirect(location, code) - - @classmethod - def reroute(cls, path): - if not hasattr(request, 'rerouting'): - request.rerouting = [request.httprequest.path] - if path in request.rerouting: - raise Exception("Rerouting loop is forbidden") - request.rerouting.append(path) - if len(request.rerouting) > cls.rerouting_limit: - raise Exception("Rerouting limit exceeded") - request.httprequest.environ['PATH_INFO'] = path - # void werkzeug cached_property. TODO: find a proper way to do this - for key in ('path', 'full_path', 'url', 'base_url'): - request.httprequest.__dict__.pop(key, None) - - return cls._dispatch() - - @classmethod - def _postprocess_args(cls, arguments, rule): - super(IrHttp, cls)._postprocess_args(arguments, rule) - - try: - _, path = rule.build(arguments) - assert path is not None - except odoo.exceptions.MissingError: - return cls._handle_exception(werkzeug.exceptions.NotFound()) - except Exception as e: - return cls._handle_exception(e) - - if getattr(request, 'is_frontend_multilang', False) and request.httprequest.method in ('GET', 'HEAD'): - generated_path = werkzeug.urls.url_unquote_plus(path) - current_path = werkzeug.urls.url_unquote_plus(request.httprequest.path) - if generated_path != current_path: - if request.lang != cls._get_default_lang(): - path = '/' + request.lang.url_code + path - if request.httprequest.query_string: - path += '?' + request.httprequest.query_string.decode('utf-8') - return request.redirect(path, code=301) + def _frontend_pre_dispatch(cls): + request.update_context(lang=request.lang._get_cached('code')) + if request.httprequest.cookies.get('frontend_lang') != request.lang._get_cached('code'): + request.future_response.set_cookie('frontend_lang', request.lang._get_cached('code')) @classmethod def _get_exception_code_values(cls, exception): @@ -560,7 +592,9 @@ class IrHttp(models.AbstractModel): exception=exception, traceback=traceback.format_exc(), ) - if isinstance(exception, exceptions.UserError): + if isinstance(exception, exceptions.AccessDenied): + code = 403 + elif isinstance(exception, exceptions.UserError): values['error_message'] = exception.args[0] code = 400 if isinstance(exception, exceptions.AccessError): @@ -592,61 +626,29 @@ class IrHttp(models.AbstractModel): return code, env['ir.ui.view']._render_template('http_routing.%s' % code, values) @classmethod - def _handle_exception(cls, exception): - is_frontend_request = bool(getattr(request, 'is_frontend', False)) - if not is_frontend_request: - # Don't touch non frontend requests exception handling - return super(IrHttp, cls)._handle_exception(exception) - try: - response = super(IrHttp, cls)._handle_exception(exception) + def _handle_error(cls, exception): + response = super()._handle_error(exception) - if isinstance(response, Exception): - exception = response - else: - # if parent excplicitely returns a plain response, then we don't touch it - return response - except Exception as e: - if 'werkzeug' in config['dev_mode']: - raise e - exception = e + is_frontend_request = bool(getattr(request, 'is_frontend', False)) + if not is_frontend_request or not isinstance(response, HTTPException): + # neither handle backend requests nor plain responses + return response + + # minimal setup to serve frontend pages + if not request.uid: + cls._auth_method_public() + request.registry['ir.http']._frontend_pre_dispatch() + request.params = request.get_http_params() code, values = cls._get_exception_code_values(exception) - if code is None: - # Hand-crafted HTTPException likely coming from abort(), - # usually for a redirect response -> return it directly - return exception - - if not request.uid: - cls._auth_method_public() - - # We rollback the current transaction before initializing a new - # cursor to avoid potential deadlocks. - - # If the current (failed) transaction was holding a lock, the new - # cursor might have to wait for this lock to be released further - # down the line. However, this will only happen after the - # request is done (and in fact it won't happen). As a result, the - # current thread/worker is frozen until its timeout is reached. - - # So rolling back the transaction will release any potential lock - # and, since we are in a case where an exception was raised, the - # transaction shouldn't be committed in the first place. - request.env.cr.rollback() - - with registry(request.env.cr.dbname).cursor() as cr: - env = api.Environment(cr, request.uid, request.env.context) - if code == 500: - _logger.error("500 Internal Server Error:\n\n%s", values['traceback']) - values = cls._get_values_500_error(env, values, exception) - elif code == 403: - _logger.warning("403 Forbidden:\n\n%s", values['traceback']) - elif code == 400: - _logger.warning("400 Bad Request:\n\n%s", values['traceback']) - try: - code, html = cls._get_error_html(env, code, values) - except Exception: - code, html = 418, env['ir.ui.view']._render_template('http_routing.http_error', values) + request.cr.rollback() + if code == 500: + values = cls._get_values_500_error(request.env, values, exception) + try: + code, html = cls._get_error_html(request.env, code, values) + except Exception: + code, html = 418, request.env['ir.ui.view']._render_template('http_routing.http_error', values) return werkzeug.wrappers.Response(html, status=code, content_type='text/html;charset=utf-8') @@ -661,8 +663,7 @@ class IrHttp(models.AbstractModel): except werkzeug.exceptions.MethodNotAllowed: endpoint = router.match(path, method='GET', query_args=query_args) except werkzeug.routing.RequestRedirect as e: - # get path from http://{path}?{current query string} - new_url = e.new_url.split('?')[0][7:] + new_url = e.new_url.split('?')[0][7:] # remove scheme _, endpoint = self.url_rewrite(new_url, query_args) endpoint = endpoint and [endpoint] except werkzeug.exceptions.NotFound: diff --git a/addons/portal/models/ir_http.py b/addons/portal/models/ir_http.py index 9320f6d4f82..6e5a53880f8 100644 --- a/addons/portal/models/ir_http.py +++ b/addons/portal/models/ir_http.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import models @@ -15,6 +14,11 @@ class IrHttp(models.AbstractModel): @classmethod def _get_frontend_langs(cls): - if request and request.is_frontend: + # _get_frontend_langs() is used by @http_routing:IrHttp._match + # where is_frontend is not yet set and when no backend endpoint + # matched. We have to assume we are going to match a frontend + # route, hence the default True. Elsewhere, request.is_frontend + # is set. + if request and getattr(request, 'is_frontend', True): return [lang[0] for lang in filter(lambda l: l[3], request.env['res.lang'].get_available())] return super()._get_frontend_langs() diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py index 36003f72f7e..bce955ae826 100644 --- a/addons/website/models/ir_http.py +++ b/addons/website/models/ir_http.py @@ -1,5 +1,5 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import contextlib import logging from lxml import etree import os @@ -15,7 +15,7 @@ from functools import partial import odoo from odoo import api, models -from odoo import registry, SUPERUSER_ID +from odoo import SUPERUSER_ID from odoo.exceptions import AccessError, MissingError from odoo.http import request from odoo.tools.safe_eval import safe_eval @@ -69,8 +69,8 @@ class Http(models.AbstractModel): @classmethod def clear_caches(cls): - super(Http, cls)._clear_routing_map() - return super(Http, cls).clear_caches() + super()._clear_routing_map() + return super().clear_caches() @classmethod def _slug_matching(cls, adapter, endpoint, **kw): @@ -80,11 +80,6 @@ class Http(models.AbstractModel): qs = request.httprequest.query_string.decode('utf-8') return adapter.build(endpoint, kw) + (qs and '?%s' % qs or '') - @classmethod - def _match(cls, path_info, key=None): - key = key or (request and request.website_routing) - return super(Http, cls)._match(path_info, key=key) - @classmethod def _generate_routing_rules(cls, modules, converters): website_id = request.website_routing @@ -94,25 +89,24 @@ class Http(models.AbstractModel): rewrites = dict([(x.url_from, x) for x in request.env['website.rewrite'].sudo().search(domain)]) cls._rewrite_len[website_id] = len(rewrites) - for url, endpoint, routing in super(Http, cls)._generate_routing_rules(modules, converters): - routing = dict(routing) + for url, endpoint in super()._generate_routing_rules(modules, converters): if url in rewrites: rewrite = rewrites[url] url_to = rewrite.url_to if rewrite.redirect_type == '308': logger.debug('Add rule %s for %s' % (url_to, website_id)) - yield url_to, endpoint, routing # yield new url + yield url_to, endpoint # yield new url if url != url_to: logger.debug('Redirect from %s to %s for website %s' % (url, url_to, website_id)) _slug_matching = partial(cls._slug_matching, endpoint=endpoint) - routing['redirect_to'] = _slug_matching - yield url, endpoint, routing # yield original redirected to new url + endpoint.routing['redirect_to'] = _slug_matching + yield url, endpoint # yield original redirected to new url elif rewrite.redirect_type == '404': logger.debug('Return 404 for %s for website %s' % (url, website_id)) continue else: - yield url, endpoint, routing + yield url, endpoint @classmethod def _get_converters(cls): @@ -120,7 +114,7 @@ class Http(models.AbstractModel): match Rule. This override adds the website ones. """ return dict( - super(Http, cls)._get_converters(), + super()._get_converters(), model=ModelConverter, ) @@ -128,16 +122,14 @@ class Http(models.AbstractModel): def _auth_method_public(cls): """ If no user logged, set the public user of current website, or default public user as request uid. - After this method `request.env` can be called, since the `request.uid` is - set. The `env` lazy property of `request` will be correct. """ if not request.session.uid: - env = api.Environment(request.cr, SUPERUSER_ID, request.context) - website = env['website'].get_current_website() - request.uid = website and website._get_cached('user_id') + website = request.env(user=SUPERUSER_ID)['website'].get_current_website() # sudo + if website: + request.update_env(user=website._get_cached('user_id')) if not request.uid: - super(Http, cls)._auth_method_public() + super()._auth_method_public() @classmethod def _register_website_track(cls, response): @@ -159,104 +151,95 @@ class Http(models.AbstractModel): return False @classmethod - def _postprocess_args(cls, arguments, rule): - processing = super()._postprocess_args(arguments, rule) - if processing: - return processing + def _match(cls, path): + if not hasattr(request, 'website_routing'): + website = request.env['website'].get_current_website() + request.website_routing = website.id + + return super()._match(path) + + @classmethod + def _pre_dispatch(cls, rule, arguments): + super()._pre_dispatch(rule, arguments) for record in arguments.values(): if isinstance(record, models.BaseModel) and hasattr(record, 'can_access_from_current_website'): try: if not record.can_access_from_current_website(): - return request.env['ir.http']._handle_exception(werkzeug.exceptions.NotFound()) + raise werkzeug.exceptions.NotFound() except AccessError: - # record.website_id might not be readable as unpublished `event.event` due to ir.rule, - # return 403 instead of using `sudo()` for perfs as this is low level - return request.env['ir.http']._handle_exception(werkzeug.exceptions.Forbidden()) + # record.website_id might not be readable as + # unpublished `event.event` due to ir.rule, return + # 403 instead of using `sudo()` for perfs as this is + # low level. + raise werkzeug.exceptions.Forbidden() @classmethod - def _dispatch(cls): - """ - In case of rerouting for translate (e.g. when visiting odoo.com/fr_BE/), - _dispatch calls reroute() that returns _dispatch with altered request properties. - The second _dispatch will continue until end of process. When second _dispatch is finished, the first _dispatch - call receive the new altered request and continue. - At the end, 2 calls of _dispatch (and this override) are made with exact same request properties, instead of one. - As the response has not been sent back to the client, the visitor cookie does not exist yet when second _dispatch call - is treated in _handle_webpage_dispatch, leading to create 2 visitors with exact same properties. - To avoid this, we check if, !!! before calling super !!!, we are in a rerouting request. If not, it means that we are - handling the original request, in which we should create the visitor. We ignore every other rerouting requests. - """ - is_rerouting = hasattr(request, 'routing_iteration') + def _get_web_editor_context(cls): + ctx = super()._get_web_editor_context() + if request.is_frontend_multilang and request.lang == cls._get_default_lang(): + ctx['edit_translations'] = False + return ctx - if request.session.db: - reg = registry(request.session.db) - with reg.cursor() as cr: - env = api.Environment(cr, SUPERUSER_ID, {}) - request.website_routing = env['website'].get_current_website().id + @classmethod + def _frontend_pre_dispatch(cls): + super()._frontend_pre_dispatch() - response = super(Http, cls)._dispatch() + if not request.context.get('tz'): + with contextlib.suppress(pytz.UnknownTimeZoneError): + tz = request.session.get('geoip', {}).get('time_zone', '') + request.update_context(tz=pytz.timezone(tz).zone) - if not is_rerouting: - cls._register_website_track(response) + website = request.env['website'].get_current_website() + user = request.env.user + + # This is mainly to avoid access errors in website controllers + # where there is no context (eg: /shop), and it's not going to + # propagate to the global context of the tab. If the company of + # the website is not in the allowed companies of the user, set + # the main company of the user. + company_id = website._get_cached('company_id') + request.update_context( + allowed_company_ids=( + [company_id] if company_id in user.company_ids.ids else user.company_id.ids + ), + website_id=website.id, + **cls._get_web_editor_context(), + ) + + request.website = website.with_context(request.context) + + @classmethod + def _dispatch(cls, endpoint): + response = super()._dispatch(endpoint) + cls._register_website_track(response) return response - @classmethod - def _add_dispatch_parameters(cls, func): - - # DEPRECATED for /website/force/ - remove me in master~saas-14.4 - # Force website with query string paramater, typically set from website selector in frontend navbar and inside tests - force_website_id = request.httprequest.args.get('fw') - if (force_website_id and request.session.get('force_website_id') != force_website_id - and request.env.user.has_group('website.group_multi_website') - and request.env.user.has_group('website.group_website_publisher')): - request.env['website']._force_website(request.httprequest.args.get('fw')) - - context = {} - if not request.context.get('tz'): - context['tz'] = request.session.get('geoip', {}).get('time_zone') - try: - pytz.timezone(context['tz'] or '') - except pytz.UnknownTimeZoneError: - context.pop('tz') - - request.website = request.env['website'].get_current_website() # can use `request.env` since auth methods are called - context['website_id'] = request.website.id - # This is mainly to avoid access errors in website controllers where there is no - # context (eg: /shop), and it's not going to propagate to the global context of the tab - # If the company of the website is not in the allowed companies of the user, set the main - # company of the user. - website_company_id = request.website._get_cached('company_id') - if website_company_id in request.env.user.company_ids.ids: - context['allowed_company_ids'] = [website_company_id] - else: - context['allowed_company_ids'] = request.env.user.company_id.ids - - # modify bound context - request.context = dict(request.context, **context) - - super(Http, cls)._add_dispatch_parameters(func) - - if request.routing_iteration == 1: - request.website = request.website.with_context(request.context) - @classmethod def _get_frontend_langs(cls): - if get_request_website(): - return [code for code, *_ in request.env['res.lang'].get_available()] + # _get_frontend_langs() is used by @http_routing:IrHttp._match + # where is_frontend is not yet set and when no backend endpoint + # matched. We have to assume we are going to match a frontend + # route, hence the default True. Elsewhere, request.is_frontend + # is set. + if getattr(request, 'is_frontend', True): + website_id = request.env.get('website_id', request.website_routing) + res_lang = request.env['res.lang'].with_context(website_id=website_id) + return [code for code, *_ in res_lang.get_available()] else: return super()._get_frontend_langs() @classmethod def _get_default_lang(cls): - if getattr(request, 'website', False): - return request.env['res.lang'].browse(request.website._get_cached('default_lang_id')) - return super(Http, cls)._get_default_lang() + if getattr(request, 'is_frontend', True): + website = request.env['website'].sudo().get_current_website() + return request.env['res.lang'].browse([website._get_cached('default_lang_id')]) + return super()._get_default_lang() @classmethod def _get_translation_frontend_modules_name(cls): - mods = super(Http, cls)._get_translation_frontend_modules_name() - installed = request.registry._init_modules | set(odoo.conf.server_wide_modules) + mods = super()._get_translation_frontend_modules_name() + installed = request.registry._init_modules.union(odoo.conf.server_wide_modules) return mods + [mod for mod in installed if mod.startswith('website')] @classmethod @@ -289,7 +272,7 @@ class Http(models.AbstractModel): page.cache_time # cache > 0 and request.httprequest.method == "GET" and request.env.user._is_public() # only cache for unlogged user - and 'nocache' not in request.params # allow bypass cache / debug + and 'nocache' not in request.httprequest.args # allow bypass cache / debug and not request.session.debug and len(cache_key) and cache_key[-1] is not None # nocache via expr ): @@ -333,15 +316,22 @@ class Http(models.AbstractModel): return request.env['website.rewrite'].sudo().search(domain, limit=1) @classmethod - def _serve_fallback(cls, exception): + def _serve_fallback(cls): # serve attachment before - parent = super(Http, cls)._serve_fallback(exception) + parent = super()._serve_fallback() if parent: # attachment return parent - if not request.is_frontend: - return False + + # minimal setup to serve frontend pages + if not request.uid: + cls._auth_method_public() + cls._frontend_pre_dispatch() + request.params = request.get_http_params() + website_page = cls._serve_page() if website_page: + website_page.flatten() + cls._register_website_track(website_page) return website_page redirect = cls._serve_redirect() @@ -351,11 +341,9 @@ class Http(models.AbstractModel): code=redirect.redirect_type, local=False) # safe because only designers can specify redirects - return False - @classmethod def _get_exception_code_values(cls, exception): - code, values = super(Http, cls)._get_exception_code_values(exception) + code, values = super()._get_exception_code_values(exception) if isinstance(exception, werkzeug.exceptions.NotFound) and request.website.is_publisher(): code = 'page_404' values['path'] = request.httprequest.path[1:] @@ -368,7 +356,7 @@ class Http(models.AbstractModel): @classmethod def _get_values_500_error(cls, env, values, exception): View = env["ir.ui.view"] - values = super(Http, cls)._get_values_500_error(env, values, exception) + values = super()._get_values_500_error(env, values, exception) if 'qweb_exception' in values: try: # exception.name might be int, string @@ -398,7 +386,7 @@ class Http(models.AbstractModel): def _get_error_html(cls, env, code, values): if code in ('page_404', 'protected_403'): return code.split('_')[1], env['ir.ui.view']._render_template('website.%s' % code, values) - return super(Http, cls)._get_error_html(env, code, values) + return super()._get_error_html(env, code, values) def binary_content(self, xmlid=None, model='ir.attachment', id=None, field='datas', unique=False, filename=None, filename_field='name', download=False, @@ -433,7 +421,7 @@ class Http(models.AbstractModel): if obj: return obj[0] - return super(Http, cls)._xmlid_to_obj(env, xmlid) + return super()._xmlid_to_obj(env, xmlid) @api.model def get_frontend_session_info(self): diff --git a/addons/website/models/res_lang.py b/addons/website/models/res_lang.py index 3c8ee9dcb52..1f1f5ac7028 100644 --- a/addons/website/models/res_lang.py +++ b/addons/website/models/res_lang.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, models, tools, _ @@ -19,8 +18,6 @@ class Lang(models.Model): @api.model @tools.ormcache_context(keys=("website_id",)) def get_available(self): - website = ir_http.get_request_website() - if not website: - return super().get_available() - # Return the website-available ones in this case - return request.website.language_ids.get_sorted() + if request and getattr(request, 'is_frontend', True): + return self.env['website'].get_current_website().language_ids.get_sorted() + return super().get_available() diff --git a/addons/website/models/website.py b/addons/website/models/website.py index 22c39daae89..da17cdb23fa 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 @@ -16,7 +15,7 @@ from werkzeug.datastructures import OrderedMultiDict from werkzeug.exceptions import NotFound from odoo import api, fields, models, tools, http, release, registry -from odoo.addons.http_routing.models.ir_http import slugify, _guess_mimetype, url_for +from odoo.addons.http_routing.models.ir_http import RequestUID, slugify, _guess_mimetype, url_for from odoo.addons.website.models.ir_http import sitemap_qs2dom from odoo.addons.website.tools import similarity_score, text_from_html from odoo.addons.portal.controllers.portal import pager @@ -925,7 +924,7 @@ class Website(models.Model): # The format of `httprequest.host` is `domain:port` domain_name = request and request.httprequest.host or '' - country = request.session.geoip.get('country_code') if request and request.session.geoip else False + country = request.session.geoip.get('country_code') if request and 'geoip' in request.session else False country_id = False if country: country_id = self.env['res.country'].search([('code', '=', country)], limit=1).id @@ -1116,7 +1115,7 @@ class Website(models.Model): return False # dont't list routes without argument having no default value or converter - sign = inspect.signature(endpoint.method.original_func) + sign = inspect.signature(endpoint.original_endpoint) params = list(sign.parameters.values())[1:] # skip self supported_kinds = (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) @@ -1138,7 +1137,6 @@ class Website(models.Model): of the same. :rtype: list({name: str, url: str}) """ - router = http.root.get_db_router(request.db) # Force enumeration to be performed as public user url_set = set() @@ -1295,30 +1293,35 @@ class Website(models.Model): """Returns the canonical URL for the current request with translatable elements appropriately translated in `lang`. - If `request.endpoint` is not true, returns the current `path` instead. + If it is not possible to rebuild a path, use the current one instead. `url_quote_plus` is applied on the returned path. """ self.ensure_one() - if request.endpoint: - router = http.root.get_db_router(request.db).bind('') - arguments = dict(request.endpoint_arguments) - for key, val in list(arguments.items()): + try: + # Re-match the controller where the request path routes. + rule, args = self.env['ir.http']._match(request.httprequest.path) + for key, val in list(args.items()): if isinstance(val, models.BaseModel): + if isinstance(val._uid, RequestUID): + args[key] = val = val.with_user(request.uid) if val.env.context.get('lang') != lang.code: - arguments[key] = val.with_context(lang=lang.code) - path = router.build(request.endpoint, arguments) - else: + args[key] = val = val.with_context(lang=lang.code) + + router = http.root.get_db_router(request.db).bind('') + path = router.build(rule.endpoint, args) + except (NotFound, AccessError): # The build method returns a quoted URL so convert in this case for consistency. path = urls.url_quote_plus(request.httprequest.path, safe='/') - lang_path = ('/' + lang.url_code) if lang != self.default_lang_id else '' - canonical_query_string = '?%s' % urls.url_encode(canonical_params) if canonical_params else '' + lang_path = f'/{lang.url_code}' if lang != self.default_lang_id else '' + canonical_query_string = f'?{urls.url_encode(canonical_params)}' if canonical_params else '' return self.get_base_url() + lang_path + path + canonical_query_string def _get_canonical_url(self, canonical_params): """Returns the canonical URL for the current request.""" self.ensure_one() - return self._get_canonical_url_localized(lang=request.lang, canonical_params=canonical_params) + lang = getattr(request, 'lang', self.env['ir.http']._get_default_lang()) + return self._get_canonical_url_localized(lang=lang, canonical_params=canonical_params) def _is_canonical_url(self, canonical_params): """Returns whether the current request URL is canonical.""" @@ -1329,10 +1332,10 @@ class Website(models.Model): canonical_params = canonical_params or OrderedMultiDict() if params != canonical_params: return False - # Compare URL at the first rerouting iteration (if available) because - # it's the one with the language in the path. - # It is important to also test the domain of the current URL. - current_url = request.httprequest.url_root[:-1] + (hasattr(request, 'rerouting') and request.rerouting[0] or request.httprequest.path) + # Compare URL at the first routing iteration because it's the one with + # the language in the path. It is important to also test the domain of + # the current URL. + current_url = request.httprequest.url_root[:-1] + request.httprequest.environ['REQUEST_URI'] canonical_url = self._get_canonical_url_localized(lang=request.lang, canonical_params=None) # A request path with quotable characters (such as ",") is never # canonical because request.httprequest.base_url is always unquoted, diff --git a/addons/website/tools.py b/addons/website/tools.py index 546757463df..c84bc41e700 100644 --- a/addons/website/tools.py +++ b/addons/website/tools.py @@ -1,26 +1,72 @@ -# -*- encoding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import contextlib from lxml import etree from unittest.mock import Mock, MagicMock, patch -import werkzeug +from werkzeug.exceptions import NotFound +from werkzeug.test import EnvironBuilder import odoo -from odoo.tools.misc import DotDict - - -def werkzeugRaiseNotFound(*args, **kwargs): - raise werkzeug.exceptions.NotFound() +from odoo.tests.common import HttpCase, HOST +from odoo.tools.misc import DotDict, frozendict @contextlib.contextmanager def MockRequest( - env, *, routing=True, multilang=True, - context=None, - cookies=None, country_code=None, website=None, sale_order_id=None, - website_sale_current_pl=None, + env, *, path='/mockrequest/', routing=True, multilang=True, + context=frozendict(), cookies=frozendict(), country_code=None, + website=None, sale_order_id=None, website_sale_current_pl=None, ): + + lang_code = context.get('lang', env.context.get('lang', 'en_US')) + env = env(context=dict(context, lang=lang_code)) + + request = Mock( + # request + httprequest=Mock( + host='localhost', + path=path, + app=odoo.http.root, + environ=dict( + EnvironBuilder( + path=path, + base_url=HttpCase.base_url() + ).get_environ(), + REMOTE_ADDR=HOST, + ), + cookies=cookies, + referrer='', + ), + type='http', + future_response=odoo.http.FutureResponse(), + params={}, + redirect=env['ir.http']._redirect, + session=DotDict( + odoo.http.DEFAULT_SESSION, + geoip={'country_code': country_code}, + sale_order_id=sale_order_id, + website_sale_current_pl=website_sale_current_pl, + ), + db=None, + env=env, + registry=env.registry, + cr=env.cr, + uid=env.uid, + context=env.context, + lang=env['res.lang']._lang_get(lang_code), + website=website, + ) + if website: + request.website_routing = website.id + + # The following code mocks match() to return a fake rule with a fake + # 'routing' attribute (routing=True) or to raise a NotFound + # exception (routing=False). + # + # router = odoo.http.root.get_db_router() + # rule, args = router.bind(...).match(path) + # # arg routing is True => rule.endpoint.routing == {...} + # # arg routing is False => NotFound exception router = MagicMock() match = router.return_value.bind.return_value.match if routing: @@ -30,36 +76,7 @@ def MockRequest( 'multilang': multilang } else: - match.side_effect = werkzeugRaiseNotFound - - if context is None: - context = {} - lang_code = context.get('lang', env.context.get('lang', 'en_US')) - context.setdefault('lang', lang_code) - - request = Mock( - context=context, - db=None, - endpoint=match.return_value[0] if routing else None, - env=env, - httprequest=Mock( - host='localhost', - path='/hello/', - app=odoo.http.root, - environ={'REMOTE_ADDR': '127.0.0.1'}, - cookies=cookies or {}, - referrer='', - ), - lang=env['res.lang']._lang_get(lang_code), - redirect=env['ir.http']._redirect, - session=DotDict( - geoip={'country_code': country_code}, - debug=False, - sale_order_id=sale_order_id, - website_sale_current_pl=website_sale_current_pl, - ), - website=website - ) + match.side_effect = NotFound with contextlib.ExitStack() as s: odoo.http._request_stack.push(request)