dynamicweb/cms/cache/page.py

272 lines
10 KiB
Python

from __future__ import annotations
import datetime
import hashlib
from collections.abc import Mapping
from datetime import timedelta
from typing import TYPE_CHECKING, Any
from django.conf import settings
from django.utils.cache import (
add_never_cache_headers,
patch_response_headers,
patch_vary_headers,
)
from django.utils.encoding import iri_to_uri
from django.utils.timezone import now
from cms.cache import _get_cache_key, _get_cache_version, _set_cache_version
from cms.constants import EXPIRE_NOW, MAX_EXPIRATION_TTL
from cms.toolbar.utils import get_toolbar_from_request
from cms.utils import get_current_site
from cms.utils.compat.response import get_response_headers
from cms.utils.conf import get_cms_setting
from cms.utils.helpers import get_timezone_name
from cms.utils.i18n import get_default_language_for_site
if TYPE_CHECKING:
from collections.abc import Iterable
from django.http import HttpRequest, HttpResponse
from cms.models import Page
def _page_cache_key(request: HttpRequest, vary_on: Iterable[str] | None = None) -> str:
"""
Generate a cache key based on the request path and language.
The language is determined following django-cms's language resolution order.
``vary_on`` is an optional iterable of header names declared by plugins via
``get_vary_cache_on()``. The request's values for those headers are always
folded into the key so that responses varying on those headers are cached
separately per value (instead of the first variant being served to
everyone). A missing (``None``) or empty ``vary_on`` hashes to a constant.
"""
site = get_current_site(request)
if hasattr(request, "LANGUAGE_CODE"):
language = request.LANGUAGE_CODE
else:
language = get_default_language_for_site(site.pk)
cache_key = "%s:%d:%s:%s" % (
get_cms_setting("CACHE_PREFIX"),
site.pk,
language,
hashlib.sha1(iri_to_uri(request.get_full_path()).encode("utf-8")).hexdigest(),
)
if settings.USE_TZ:
cache_key += ".%s" % get_timezone_name()
cache_key += ".%s" % _vary_on_hash(request, vary_on or [])
return cache_key
def _vary_on_hash(request: HttpRequest, vary_on: Iterable[str]) -> str:
"""Hash of the request's values for the given (plugin-declared) headers."""
ctx = hashlib.sha1()
for header in sorted(header.lower() for header in vary_on):
# Mirror Django's translation of header names to ``request.META`` keys.
meta_key = "HTTP_" + header.upper().replace("-", "_")
value = request.META.get(meta_key, "")
ctx.update(("%s=%s&" % (header, iri_to_uri(value))).encode("utf-8"))
return ctx.hexdigest()
def _page_vary_headers_cache_key(request: HttpRequest) -> str:
"""Key under which the list of plugin-declared vary headers is stored.
The list cannot be known on a cache read until the page is rendered, so it
is persisted on write and looked up first on read (mirroring Django's
``learn_cache_key`` / ``get_cache_key`` two-step approach).
"""
return _page_cache_key(request) + ".vary-on"
def set_page_cache(response: HttpResponse) -> HttpResponse:
"""Store a rendered page response in the CMS page cache.
The response is only cached for anonymous requests when the page cache is
enabled and the toolbar has not disabled caching. The cache timeout is the
smallest of the configured ``content`` duration and every cache-able
placeholder's TTL (as returned by ``get_cache_expiration()``); if any
placeholder requests ``EXPIRE_NOW`` the response is not cached at all.
When the response is cacheable, expiration and ``Vary`` headers are patched
onto it and two entries are written: the list of plugin-declared vary
headers (see :func:`_page_vary_headers_cache_key`) and the response payload
(content, headers and absolute expiry timestamp) under the header-aware
content key. Otherwise ``never cache`` headers are added.
Returns the (possibly header-patched) ``response``.
"""
from django.core.cache import cache
request = response._request
toolbar = get_toolbar_from_request(request)
is_authenticated = request.user.is_authenticated
if is_authenticated or toolbar._cache_disabled or not get_cms_setting("PAGE_CACHE"):
add_never_cache_headers(response)
return response
# This *must* be TZ-aware
timestamp = now()
placeholders = toolbar.content_renderer.get_rendered_placeholders()
# Checks if there's a plugin using the legacy "cache = False"
placeholder_ttl_list = []
vary_cache_on_set = set()
for ph in placeholders:
# get_cache_expiration() always returns:
# EXPIRE_NOW <= int <= MAX_EXPIRATION_IN_SECONDS
ttl = ph.get_cache_expiration(request, timestamp)
vary_cache_on = ph.get_vary_cache_on(request)
placeholder_ttl_list.append(ttl)
if ttl and vary_cache_on:
# We're only interested in vary headers if they come from
# a cache-able placeholder.
vary_cache_on_set |= set(vary_cache_on)
if EXPIRE_NOW not in placeholder_ttl_list:
if placeholder_ttl_list:
min_placeholder_ttl = min(x for x in placeholder_ttl_list)
else:
# Should only happen when there are no placeholders at all
min_placeholder_ttl = MAX_EXPIRATION_TTL
ttl = min(get_cms_setting("CACHE_DURATIONS")["content"], min_placeholder_ttl)
if ttl > 0:
# Adds expiration, etc. to headers
vary_on = sorted(vary_cache_on_set)
patch_response_headers(response, cache_timeout=ttl)
patch_vary_headers(response, vary_on)
version = _get_cache_version()
# We also store the absolute expiration timestamp to avoid
# recomputing it on cache-reads.
expires_datetime = timestamp + timedelta(seconds=ttl)
response_headers = get_response_headers(response)
# Persist whether this page opted out of Django's clickjacking
# middleware. Only "Allow" pages (no X-Frame-Options header) set
# xframe_options_exempt; "Inherit" pages leave it unset so the
# middleware adds the site default. The read path must replay this
# decision -- unconditionally exempting a cached response would drop
# X-Frame-Options from every inherit-default page (clickjacking).
xframe_options_exempt = getattr(response, "xframe_options_exempt", False)
# Persist the list of plugin-declared vary headers so the read path
# can rebuild the same (header-value aware) content key.
cache.set(
_page_vary_headers_cache_key(request),
vary_on,
ttl,
version=version,
)
cache.set(
_page_cache_key(request, vary_on),
(
response.content,
response_headers,
expires_datetime,
xframe_options_exempt,
),
ttl,
version=version,
)
# See note in invalidate_cms_page_cache()
_set_cache_version(version)
return response
def get_page_cache(request: HttpRequest) -> tuple[bytes, Mapping[str, str], datetime] | None:
"""Return the cached page response for ``request`` or ``None``.
Mirrors Django's two-step ``get_cache_key`` lookup: first the list of
plugin-declared vary headers is read, then the request's values for those
headers are folded into the content key so the matching variant is
returned. The cached value is the ``(content, headers, expires_datetime)``
tuple stored by :func:`set_page_cache`, or ``None`` on a cache miss.
"""
from django.core.cache import cache
version = _get_cache_version()
# First resolve which headers (if any) the cached page varies on, then
# build the content key from this request's values for those headers.
vary_on = cache.get(_page_vary_headers_cache_key(request), version=version)
return cache.get(_page_cache_key(request, vary_on), version=version)
def get_xframe_cache(page: Page) -> int | None:
"""Return the cached ``X-Frame-Options`` value for ``page`` or ``None``."""
from django.core.cache import cache
return cache.get("cms:xframe_options:%s" % page.pk)
def set_xframe_cache(page: Page, xframe_options: int) -> None:
"""Cache the resolved ``X-Frame-Options`` value for ``page``.
The cache version is re-written afterwards so the version key always
outlives the entries written against it (see
:func:`cms.cache.invalidate_cms_page_cache`).
"""
from django.core.cache import cache
cache.set("cms:xframe_options:%s" % page.pk, xframe_options, version=_get_cache_version())
_set_cache_version(_get_cache_version())
def _page_url_key(
page_lookup: Any,
lang: str,
site_id: int,
extra_key: str | None,
) -> str:
"""Build the cache key for a page's absolute URL.
``page_lookup`` is the untyped page reference accepted by the ``page_url``
template tag (a :class:`~cms.models.Page`, pk, reverse id, ...). When
``extra_key`` is a string it is folded into the key so callers can cache
distinct URLs (e.g. per query string) for the same page.
"""
return "".join([
_get_cache_key("page_url", page_lookup, lang, site_id),
f"_key:{extra_key}" if isinstance(extra_key, str) else "",
"_type:absolute_url",
])
def set_page_url_cache(
page_lookup: Any,
lang: str,
site_id: int,
url: str,
extra_key: str | None = None,
) -> None:
"""Cache the absolute ``url`` of a page for the ``content`` duration.
The cache version is re-written afterwards so the version key always
outlives the entries written against it (see
:func:`cms.cache.invalidate_cms_page_cache`).
"""
from django.core.cache import cache
cache.set(
_page_url_key(page_lookup, lang, site_id, extra_key),
url,
get_cms_setting("CACHE_DURATIONS")["content"],
version=_get_cache_version(),
)
_set_cache_version(_get_cache_version())
def get_page_url_cache(
page_lookup: Any,
lang: str,
site_id: int,
extra_key: str | None = None,
) -> str | None:
"""Return the cached absolute URL for a page lookup, or ``None`` on a miss."""
from django.core.cache import cache
return cache.get(_page_url_key(page_lookup, lang, site_id, extra_key), version=_get_cache_version())