forked from ungleich-public/dynamicweb
272 lines
10 KiB
Python
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())
|