from urllib.parse import quote, urlsplit, urlunsplit from django.apps import apps from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME, login as auth_login from django.contrib.auth.views import redirect_to_login from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.http import ( Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, ) from django.shortcuts import render from django.template.defaultfilters import title from django.template.response import TemplateResponse from django.urls import ( NoReverseMatch, Resolver404, get_script_prefix, resolve, reverse, ) from django.utils.cache import patch_cache_control from django.utils.encoding import iri_to_uri from django.utils.http import url_has_allowed_host_and_scheme from django.utils.timezone import now from django.utils.translation import activate from django.views.decorators.http import require_POST from cms.apphook_pool import apphook_pool from cms.appresolver import applications_page_check from cms.cache.page import get_page_cache from cms.exceptions import LanguageError from cms.forms.login import CMSToolbarLoginForm from cms.models import Page, PageContent from cms.page_rendering import ( _handle_no_apphook, _handle_no_page, _render_welcome_page, render_pagecontent, ) from cms.toolbar.utils import get_object_preview_url, get_toolbar_from_request from cms.utils import get_current_site from cms.utils.conf import get_cms_setting from cms.utils.helpers import is_editable_model from cms.utils.i18n import ( get_default_language_for_site, get_fallback_languages, get_language_list, get_public_languages, get_redirect_on_fallback, is_language_prefix_patterns_used, ) from cms.utils.page import get_page_from_request from cms.utils.page_permissions import user_can_view_page from cms.utils.permissions import user_can_view_placeholder_source from cms.utils.placeholder import get_declared_placeholders_for_obj, get_placeholder_conf def _clean_redirect_url(redirect_url, language): if (redirect_url and is_language_prefix_patterns_used() and redirect_url[0] == "/" and not redirect_url.startswith( f"/{language}/" )): # add language prefix to url redirect_url = f"/{language}/{redirect_url.lstrip('/')}" return redirect_url def details(request, slug): """ The main view of the Django-CMS! Takes a request and a slug, renders the page. """ is_authenticated = request.user.is_authenticated response_timestamp = now() if get_cms_setting("PAGE_CACHE") and ( not hasattr(request, 'toolbar') or ( not request.toolbar.edit_mode_active and not request.toolbar.show_toolbar and not is_authenticated ) ): cache_content = get_page_cache(request) if cache_content is not None: # ``xframe_options_exempt`` is absent on cache entries written before # this field was added; default to True to preserve their behaviour # until they expire. content, headers, expires_datetime, *rest = cache_content xframe_options_exempt = rest[0] if rest else True response = HttpResponse(content) # Replay the page's clickjacking decision. For "Inherit" pages this # is False, so Django's XFrameOptionsMiddleware still adds the site # default X-Frame-Options header to the cached response. response.xframe_options_exempt = xframe_options_exempt response.headers = headers # Recalculate the max-age header for this cached response max_age = int( (expires_datetime - response_timestamp).total_seconds() + 0.5) patch_cache_control(response, max_age=max_age) return response # Get a Page model object from the request site = get_current_site(request) page = get_page_from_request(request, use_path=slug) if page and page.is_page_type: # Page types are blueprints used to create new pages, not content: they # must not be served on the public site. The admin renders them through # the object preview/edit endpoints instead. return _handle_no_page(request) if not page and not slug and not Page.objects.on_site(site).exists(): # render the welcome page if the requested path is root "/" # and there's no pages return _render_welcome_page(request) if not page and get_cms_setting("REDIRECT_TO_LOWERCASE_SLUG"): # Redirect to the lowercase version of the slug if slug.lower() != slug: # Only redirect if the slug changes redirect_url = reverse("pages-details-by-slug", kwargs={"slug": slug.lower()}) if get_cms_setting('REDIRECT_PRESERVE_QUERY_PARAMS'): query_string = request.META.get('QUERY_STRING') if query_string: redirect_url += "?" + query_string return HttpResponseRedirect(redirect_url) if not page: # raise 404 or redirect to PageContent's # changelist in the admin if this is a # request to the root URL return _handle_no_page(request) request.current_page = page request.site = site if hasattr(request, 'user') and request.user.is_staff: user_languages = get_language_list(site_id=site.pk) else: user_languages = get_public_languages(site_id=site.pk) request_language = None if hasattr(request, "LANGUAGE_CODE"): # use language from middleware - usually django.middleware.locale.LocaleMiddleware request_language = request.LANGUAGE_CODE if not request_language: request_language = get_default_language_for_site(site.pk) if not page.is_home and request_language not in user_languages: # The homepage is treated differently because # when a request goes to the root of the site (/) # without a language, Django will redirect to the user's # browser language which might not be a valid cms language, # this means we need to correctly redirect that request. return _handle_no_page(request) # we use the _get_page_content_cache method to populate the cache with all public languages # The languages are then filtered out by the user allowed languages page._get_page_content_cache(None, fallback=True, force_reload=True) pagecontent_languages = list(page.page_content_cache.keys()) available_languages = [ language for language in user_languages if language in pagecontent_languages ] own_urls = [ request.build_absolute_uri(request.path), '/%s' % request.path, request.path, ] try: redirect_on_fallback = get_redirect_on_fallback(request_language, site_id=site.pk) except LanguageError: redirect_on_fallback = False if request_language not in user_languages: # Language is not allowed # Use the default site language default_language = get_default_language_for_site(site.pk) fallbacks = get_fallback_languages(default_language, site_id=site.pk) fallbacks = [default_language] + fallbacks else: fallbacks = get_fallback_languages(request_language, site_id=site.pk) # Only fallback to languages the user is allowed to see fallback_languages = [ language for language in fallbacks if language != request_language and language in available_languages ] language_is_unavailable = request_language not in available_languages first_fallback_language = next(iter(fallback_languages or []), None) if language_is_unavailable and not fallback_languages: # There is no page with the requested language # and there's no configured fallbacks return _handle_no_page(request) elif language_is_unavailable and redirect_on_fallback: # There is no page with the requested language and # redirect_on_fallback is True, # so redirect to the first configured / available fallback language redirect_url = page.get_absolute_url( first_fallback_language, fallback=False) else: page_path = page.get_absolute_url(request_language) page_slug = page.get_path(request_language) or page.get_slug(request_language) if slug and slug != page_slug and request.path[:len(page_path)] != page_path: # The current language does not match its slug. # Redirect to the current language. return HttpResponseRedirect(page_path) # Check if the page has a redirect url defined for this language. redirect_url = page.get_redirect(request_language, fallback=False) or '' redirect_url = _clean_redirect_url(redirect_url, request_language) if redirect_url: if redirect_url not in own_urls: if get_cms_setting('REDIRECT_PRESERVE_QUERY_PARAMS'): query_string = request.META.get('QUERY_STRING') if query_string: redirect_url += "?" + query_string # prevent redirect to self return HttpResponseRedirect(redirect_url) # permission checks if page.login_required and not request.user.is_authenticated: return redirect_to_login(quote(request.get_full_path()), settings.LOGIN_URL) content_language = request_language if language_is_unavailable: # When redirect_on_fallback is False and # language is unavailable, render the content # in the first fallback language available # by switching to it content_language = first_fallback_language # translation.activate() is used without context # as the context won't be preserved when the # plugins get rendered activate(content_language) content = page.get_content_obj(language=content_language) # use the page object with populated cache content.page = page if hasattr(request, 'toolbar'): request.toolbar.set_object(content) return render_pagecontent(request, content) def _get_login_redirect_url(request, redirect_to): """Return a safe redirect target for the toolbar login view. The ``next`` parameter is fully controlled by the client, so it must never be trusted as-is (CWE-601, open redirect). A target is only accepted when * it stays on the current host and uses an allowed scheme, and * its path can be resolved against the project's URL configuration, i.e. it actually points at a view served by this site. Anything else falls back to the CMS root. The returned URL is rebuilt from the validated, host-less components, so neither the scheme nor the host supplied by the client is ever echoed back into the redirect. """ fallback = reverse("pages-root") if not redirect_to or not url_has_allowed_host_and_scheme( url=redirect_to, allowed_hosts={request.get_host()} ): return fallback parts = urlsplit(redirect_to) # ``resolve()`` expects a path relative to the URL configuration, i.e. # without the (optional) script prefix that the client-supplied URL carries. path = parts.path script_prefix = get_script_prefix() if script_prefix != "/" and path.startswith(script_prefix): path = "/" + path[len(script_prefix):] try: resolve(path) except Resolver404: return fallback # Rebuild the redirect from the validated components only, dropping the # client-supplied scheme and host. ``parts.path`` keeps the script prefix # (the browser needs it); only the copy passed to ``resolve()`` had it # stripped. ``iri_to_uri`` encodes unsafe characters without # double-encoding an already percent-encoded path. return iri_to_uri(urlunsplit(("", "", parts.path, parts.query, parts.fragment))) @require_POST def login(request): redirect_to = _get_login_redirect_url(request, request.GET.get(REDIRECT_FIELD_NAME)) if request.user.is_authenticated: return HttpResponseRedirect(redirect_to) form = CMSToolbarLoginForm(request=request, data=request.POST) if form.is_valid(): auth_login(request, form.user_cache) else: # Add the error flag to the query component so it is preserved next to # any existing parameters and stays ahead of a possible fragment. parts = urlsplit(redirect_to) query = f'{parts.query}&cms_toolbar_login_error=1' if parts.query else 'cms_toolbar_login_error=1' redirect_to = urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment)) return HttpResponseRedirect(redirect_to) def render_object_structure(request, content_type_id, object_id): try: content_type = ContentType.objects.get_for_id(content_type_id) except ContentType.DoesNotExist as err: raise Http404 from err try: if issubclass(content_type.model_class(), PageContent): content_type_obj = PageContent._base_manager.select_related("page", "page__site").get(pk=object_id) request.current_page = content_type_obj.page # Enforce the same page-view permission as the edit and preview # endpoints (which check it via render_page()). Without this, a # staff user who cannot view a restricted page could still read its # plugin structure through this endpoint. if not user_can_view_page(request.user, content_type_obj.page): raise Http404 else: content_type_obj = content_type.get_object_for_this_type(pk=object_id) # Viewing the structure board requires only view permission (as the # page branch above does via user_can_view_page). Mutations remain # gated by change permission at the plugin endpoints. if not user_can_view_placeholder_source(request.user, content_type_obj): raise Http404 except ObjectDoesNotExist as err: raise Http404 from err context = { 'object': content_type_obj, 'cms_toolbar': request.toolbar, } toolbar = get_toolbar_from_request(request) toolbar.set_object(content_type_obj) return render(request, 'cms/toolbar/structure.html', context) def render_placeholder_content(request, obj, context): context["cms_placeholder_slots"] = ( ( placeholder.slot, get_placeholder_conf("name", placeholder.slot, default=title(placeholder.slot)), placeholder.inherit, ) for placeholder in get_declared_placeholders_for_obj(obj) ) return TemplateResponse(request, "cms/headless/placeholder.html", context) def render_object_endpoint(request, content_type_id, object_id, require_editable): try: content_type = ContentType.objects.get_for_id(content_type_id) except ContentType.DoesNotExist as err: raise Http404 from err else: model = content_type.model_class() if require_editable and not is_editable_model(model): return HttpResponseBadRequest('Requested object does not support frontend rendering') try: if issubclass(model, PageContent): # An apphook might be attached to a PageContent object content_type_obj = model.admin_manager.select_related("page", "page__site").get(pk=object_id) request.current_page = content_type_obj.page if ( content_type_obj.page.application_urls and content_type_obj.page.application_urls in dict(apphook_pool.get_apphooks()) and (not require_editable or content_type_obj.is_editable(request)) ): try: # If so, try get the absolute URL and pass it to the toolbar as request_path # The apphook's view function will be called. absolute_url = content_type_obj.get_absolute_url() from cms.toolbar.toolbar import CMSToolbar request.toolbar = CMSToolbar(request, request_path=absolute_url) # Make page content's placeholders available, can be overwritten by apphook view request.toolbar.set_object(content_type_obj) # Resolve the apphook's url to get its view function view_func, args, kwargs = resolve(absolute_url) if view_func is not details: return view_func(request, *args, **kwargs) except Resolver404: # Apphook does not provide a view for its "root", show warning message return _handle_no_apphook(request) else: content_type_obj = content_type.get_object_for_this_type(pk=object_id) # Enforce the same object-level view check as the structure endpoint # (and, for the page branch above, render_page()'s user_can_view_page). # Without this, any staff user could read the rendered plugin content # of any frontend-editable object. Mutations remain gated by change # permission at the plugin endpoints. if not user_can_view_placeholder_source(request.user, content_type_obj): raise Http404 except ObjectDoesNotExist as err: raise Http404 from err # Attempt to resolve current_page via the object's absolute URL (for apphooks) if not getattr(request, "current_page", None): try: object_url = content_type_obj.get_absolute_url() except (AttributeError, NoReverseMatch): object_url = None if object_url: original_path_info = request.path_info try: # Temporarily patch the request object and use the appresolver request.path_info = object_url request.current_page = applications_page_check(request) finally: request.path_info = original_path_info extension = apps.get_app_config('cms').cms_extension if model not in extension.toolbar_enabled_models: return HttpResponseBadRequest('Requested object does not support frontend rendering') toolbar = get_toolbar_from_request(request) toolbar.set_object(content_type_obj) request.site = get_current_site(request) redirect = getattr(content_type_obj, "redirect", None) if isinstance(redirect, str): toolbar.redirect_url = redirect if require_editable and not toolbar.object_is_editable(): # If not editable, switch from edit to preview endpoint return HttpResponseRedirect(get_object_preview_url(content_type_obj)) render_func = extension.toolbar_enabled_models[model] return render_func(request, content_type_obj) def render_object_edit(request, content_type_id, object_id): return render_object_endpoint(request, content_type_id, object_id, require_editable=True) def render_object_preview(request, content_type_id, object_id): return render_object_endpoint(request, content_type_id, object_id, require_editable=False)