dynamicweb/cms/views.py

448 lines
19 KiB
Python

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)