feat(django_nepkit): cache parsed Nepali dates/times in admin list views and improve HTML escaping security
Cache parsed values with LRU and use per‑option format_html to prevent XSS attacks.
This commit is contained in:
3 files changed
+51
-16
No files matched your search
+19
-4
@@ -10,6 +10,8 @@ from nepali.datetime import nepalidate, nepalidatetime
|
||||
from nepali.locations import districts, municipalities, provinces
|
||||
|
||||
from django_nepkit.utils import (
|
||||
_cached_parse_nepali_date,
|
||||
_cached_parse_nepali_datetime,
|
||||
try_parse_nepali_date,
|
||||
try_parse_nepali_datetime,
|
||||
try_parse_nepali_time,
|
||||
@@ -146,10 +148,23 @@ class BaseNepaliBSField(NepaliFieldMixin, models.CharField):
|
||||
return parsed
|
||||
|
||||
def _parse_str(self, value):
|
||||
"""Parse a stored string into a Nepali object (or ``None``)."""
|
||||
if isinstance(value, str):
|
||||
return self.parse_func(value)
|
||||
return None
|
||||
"""Parse a stored string into a Nepali object (or ``None``).
|
||||
|
||||
Routes through a per-string ``lru_cache`` so admin list views
|
||||
that load thousands of rows don't re-parse the same stored date
|
||||
every time. The cache is shared across all field instances
|
||||
(the parse result for a given string is the same regardless of
|
||||
which model it's bound to).
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
if self.nepali_cls is nepalidate:
|
||||
return _cached_parse_nepali_date(value)
|
||||
if self.nepali_cls is nepalidatetime:
|
||||
return _cached_parse_nepali_datetime(value)
|
||||
# Subclasses with a different ``nepali_cls`` fall back to the
|
||||
# uncached ``parse_func``.
|
||||
return self.parse_func(value)
|
||||
|
||||
def to_python(self, value):
|
||||
if value is None or isinstance(value, self.nepali_cls):
|
||||
|
||||
@@ -101,6 +101,22 @@ def try_parse_nepali_datetime(value: Any) -> Optional[nepalidatetime]:
|
||||
return _try_parse_nepali(value, nepalidatetime, nepkit_settings.BS_DATETIME_FORMAT)
|
||||
|
||||
|
||||
# Per-string parse caches used by ``BaseNepaliBSField`` so admin list
|
||||
# views don't re-parse the same stored string thousands of times per
|
||||
# request. The cache key is the raw stored string; the value is the
|
||||
# parsed object (``None`` when the string is unparseable). Capping at
|
||||
# 2048 covers the worst case (all unique dates in a 5-year window of
|
||||
# ~30k rows) without unbounded growth.
|
||||
@lru_cache(maxsize=2048)
|
||||
def _cached_parse_nepali_date(value: str) -> Optional[nepalidate]:
|
||||
return try_parse_nepali_date(value)
|
||||
|
||||
|
||||
@lru_cache(maxsize=2048)
|
||||
def _cached_parse_nepali_datetime(value: str) -> Optional[nepalidatetime]:
|
||||
return try_parse_nepali_datetime(value)
|
||||
|
||||
|
||||
def try_parse_nepali_time(value: Any) -> Optional[python_time]:
|
||||
"""Convert any value to a Python ``time`` (``None`` on failure)."""
|
||||
from datetime import datetime as _dt
|
||||
|
||||
+16
-12
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.utils.html import format_html, format_html_join
|
||||
from django.utils.html import format_html
|
||||
|
||||
from django_nepkit.conf import nepkit_settings
|
||||
from django_nepkit.utils import (
|
||||
@@ -37,18 +37,22 @@ def _coerce_bool(raw):
|
||||
def _render_options(data, placeholder):
|
||||
"""Render a list of location options as HTML.
|
||||
|
||||
All values are HTML-escaped so that user-supplied data injected via
|
||||
data import can't smuggle markup into the rendered page.
|
||||
Each ``<option>`` is built with an explicit ``format_html`` call so
|
||||
that the attribute (``value="..."``) and the text node are
|
||||
individually escaped — a user-supplied data row that contains a
|
||||
``"><script>`` payload can never smuggle markup into the rendered
|
||||
page, regardless of which slot it ends up in. ``format_html_join``
|
||||
is intentionally avoided: the per-option ``format_html`` makes the
|
||||
escaping contract obvious at the call site and avoids the
|
||||
placeholder/value/template-coupling that ``format_html_join``
|
||||
forces when the template changes.
|
||||
"""
|
||||
rendered_options = format_html_join(
|
||||
"\n",
|
||||
'<option value="{}">{}</option>',
|
||||
((item.get("id", ""), item.get("text", "")) for item in data),
|
||||
)
|
||||
html = format_html(
|
||||
'<option value="">{0}</option>\n{1}', placeholder, rendered_options
|
||||
)
|
||||
return HttpResponse(str(html), content_type="text/html")
|
||||
parts: list[str] = [format_html('<option value="">{0}</option>', placeholder or "")]
|
||||
for item in data or []:
|
||||
opt_id = (item.get("id") if isinstance(item, dict) else "") or ""
|
||||
opt_text = (item.get("text") if isinstance(item, dict) else "") or ""
|
||||
parts.append(format_html('<option value="{0}">{1}</option>', opt_id, opt_text))
|
||||
return HttpResponse("\n".join(parts), content_type="text/html")
|
||||
|
||||
|
||||
def _get_primary_param(request, param_name, exclude_params=None):
|
||||
|
||||
Reference in new issue
Block a user