= 1%``). Pojedynczy znak ``<`` bez
+zamykającego ``>`` był traktowany przez minifikator HTML jak otwarcie
+znacznika, który połykał dalszy markup (w tym zamykające znaczniki
+prawej kolumny) — cała strona zlewała się do jednej kolumny, a tekst
+streszczenia wyświetlał się jako posortowana sieczka słów.
+
+Streszczenia są teraz renderowane przez filtr ``|safe_streszczenie``,
+który escape'uje gołe operatory ``<``/``>`` i sanityzuje pozostały
+markup (usuwa m.in. znaczniki JATS z importu Crossref oraz ewentualny
+kod XSS), oddając poprawny, zbalansowany HTML. Dotyczy to widoku
+rekordu oraz listy najnowszych streszczeń na stronie uczelni.
diff --git a/src/bpp/templates/browse/praca_tabela.html b/src/bpp/templates/browse/praca_tabela.html
index 7c4ab7116..48cb27d90 100644
--- a/src/bpp/templates/browse/praca_tabela.html
+++ b/src/bpp/templates/browse/praca_tabela.html
@@ -72,7 +72,7 @@
| Streszczenie: |
{% for streszczenie in praca.streszczenia.all %}
- {{ streszczenie.streszczenie|safe }}
+ {{ streszczenie.streszczenie|safe_streszczenie }}
{% endfor %}
|
diff --git a/src/bpp/templates/browse/praca_tabela_mono.html b/src/bpp/templates/browse/praca_tabela_mono.html
index ff8d26b61..225d2f15f 100644
--- a/src/bpp/templates/browse/praca_tabela_mono.html
+++ b/src/bpp/templates/browse/praca_tabela_mono.html
@@ -206,7 +206,7 @@
{{ streszczenie.jezyk_streszczenia.nazwa }}:
{% endif %}
-
{{ streszczenie.streszczenie|safe }}
+ {{ streszczenie.streszczenie|safe_streszczenie }}
{% endfor %}
diff --git a/src/bpp/templates/browse/praca_tabela_new.html b/src/bpp/templates/browse/praca_tabela_new.html
index 8ab46efda..a982280e3 100644
--- a/src/bpp/templates/browse/praca_tabela_new.html
+++ b/src/bpp/templates/browse/praca_tabela_new.html
@@ -128,7 +128,7 @@
{{ streszczenie.jezyk_streszczenia.nazwa }}:
{% endif %}
-
{{ streszczenie.streszczenie|safe }}
+ {{ streszczenie.streszczenie|safe_streszczenie }}
{% endfor %}
diff --git a/src/bpp/templates/browse/uczelnia.html b/src/bpp/templates/browse/uczelnia.html
index 0088987e8..770ea82af 100644
--- a/src/bpp/templates/browse/uczelnia.html
+++ b/src/bpp/templates/browse/uczelnia.html
@@ -1,5 +1,5 @@
{% extends "base.html" %}
-{% load user_in_group deklinacja cache media_utils static %}
+{% load user_in_group deklinacja cache media_utils static prace %}
{% block extratitle %}
Uczelnia
@@ -821,7 +821,7 @@
{{ rekord.rekord.opis_bibliograficzny_cache|safe }}
- {{ rekord.streszczenie|truncatewords_html:130|safe }}
+ {{ rekord.streszczenie|safe_streszczenie|truncatewords_html:130|safe }}
{{ rekord.ostatnio_zmieniony|date:"d.m.Y H:i" }}
diff --git a/src/bpp/templatetags/prace.py b/src/bpp/templatetags/prace.py
index 061fbbbfc..cff8143b5 100644
--- a/src/bpp/templatetags/prace.py
+++ b/src/bpp/templatetags/prace.py
@@ -103,6 +103,20 @@ def ladne_numery_prac(arr):
register.filter(ladne_numery_prac)
+@register.filter(name="safe_streszczenie")
+def safe_streszczenie(value):
+ """Wyrenderuj streszczenie bezpiecznie.
+
+ Zamiennik dla ``|safe`` przy treści streszczeń: escape'uje matematyczne
+ operatory '<'/'>' wpisane w tekst (np. "<30 IU/dL"), usuwa niedozwolony
+ markup (w tym JATS i ewentualny XSS) i oddaje zbalansowany HTML, którego
+ minifikator HTML nie zepsuje. Bez tego goły '<' pożerał układ strony.
+ """
+ from bpp.util import safe_streszczenie_html
+
+ return mark_safe(safe_streszczenie_html(value))
+
+
@register.filter(name="jsonify")
def jsonify(value):
"""Convert a value to JSON string for use in JSON-LD structured data."""
diff --git a/src/bpp/tests/test_templatetags/test_safe_streszczenie.py b/src/bpp/tests/test_templatetags/test_safe_streszczenie.py
new file mode 100644
index 000000000..795f25078
--- /dev/null
+++ b/src/bpp/tests/test_templatetags/test_safe_streszczenie.py
@@ -0,0 +1,84 @@
+"""Regresja layoutu rekordu rozbijanego przez treść streszczenia.
+
+Streszczenia importowane z zewnątrz zawierają operatory porównania wpisane
+wprost w tekst (np. ``<30 IU/dL``). Renderowane przez ``|safe`` i przepuszczone
+przez produkcyjny minifikator HTML (``django-minify-html``) ``<`` bez
+zamykającego ``>`` był traktowany jak otwarcie znacznika, który połykał dalszy
+markup (w tym zamykające ``
``) — prawa kolumna rekordu lądowała
+wewnątrz lewej i strona zlewała się do jednej kolumny.
+
+Filtr ``|safe_streszczenie`` escape'uje te operatory, więc minifikator widzi
+poprawny, zbalansowany HTML.
+"""
+
+import lxml.html
+import minify_html
+from django.template import Context, Template
+
+# Args identyczne z produkcyjnym BppMinifyHtmlMiddleware.minify_args
+# (django_bpp/settings/production.py).
+PROD_MINIFY_ARGS = dict(
+ minify_css=True,
+ minify_js=True,
+ keep_input_type_text_attr=True,
+ keep_closing_tags=True,
+)
+
+# Fragment realnego streszczenia (rekord 586 z bpp.ihit.waw.pl), który rozbijał
+# layout: '<' jako operator "mniejsze niż", bez zamykającego '>'.
+BROKEN_ABSTRACT = (
+ "VWD1 with a VWF antigen level (VWF:Ag) of <30IU/dL or <40IU/dL, "
+ "in which about 80% of patients exhibited VWF gene mutations."
+)
+
+PAGE_TEMPLATE = (
+ "{% load prace %}"
+ ""
+ "
"
+ "{{ abstract|FILTER }}"
+ "
"
+ "
RIGHT_COLUMN_MARKER
"
+ "
"
+)
+
+
+def _render_and_minify(filter_expr, abstract):
+ template_str = PAGE_TEMPLATE.replace("FILTER", filter_expr)
+ rendered = Template(template_str).render(Context({"abstract": abstract}))
+ return minify_html.minify(
+ "" + rendered + "",
+ **PROD_MINIFY_ARGS,
+ )
+
+
+def _columns(html):
+ tree = lxml.html.fromstring(html)
+ left = tree.xpath("//div[contains(@class,'left-column')]")[0]
+ right = tree.xpath("//div[contains(@class,'right-column')]")[0]
+ return left, right
+
+
+def test_safe_streszczenie_keeps_columns_separate_after_minify():
+ html = _render_and_minify("safe_streszczenie", BROKEN_ABSTRACT)
+ left, right = _columns(html)
+
+ # prawa kolumna MUSI być rodzeństwem lewej, a nie jej dzieckiem
+ assert right not in left.iter(), (
+ "right-column wessana do left-column -- streszczenie zjadło "
+ "zamykające znaczniki (regresja layoutu jednokolumnowego)"
+ )
+ # operatory porównania przetrwały jako czytelny tekst, bez utraty treści
+ assert "<30IU/dL" in html
+ assert "<30IU/dL" in left.text_content()
+ assert "80% of patients exhibited" in left.text_content()
+
+
+def test_raw_safe_breaks_columns_after_minify():
+ """Dokumentuje pierwotny błąd i potwierdza, że powyższy test ma "zęby".
+
+ Surowe ``|safe`` + minifikator wsysa zamykające znaczniki, więc prawa
+ kolumna staje się potomkiem lewej.
+ """
+ html = _render_and_minify("safe", BROKEN_ABSTRACT)
+ left, right = _columns(html)
+ assert right in left.iter()
diff --git a/src/bpp/tests/test_util.py b/src/bpp/tests/test_util.py
index c49110497..30d965cac 100644
--- a/src/bpp/tests/test_util.py
+++ b/src/bpp/tests/test_util.py
@@ -3,6 +3,7 @@
from bpp.util import (
fulltext_tokenize,
knapsack,
+ safe_streszczenie_html,
strip_html,
strip_nonalphanumeric,
wytnij_isbn_z_uwag,
@@ -91,6 +92,73 @@ def test_strip_nonalphanumeric(i, o):
assert strip_nonalphanumeric(i) == o
+def test_safe_streszczenie_html_math_less_than_is_escaped_not_swallowed():
+ # Regresja: streszczenie "<30IU/dL ... <40IU/dL" (operator porównania,
+ # bez zamykającego '>') było traktowane przez minifikator jak otwarty
+ # znacznik i pożerało resztę strony (prawą kolumnę rekordu).
+ raw = "VWF:Ag) of <30IU/dL or <40IU/dL, in which about 80% exhibited"
+ out = safe_streszczenie_html(raw)
+ assert "<30IU/dL" in out
+ assert "<40IU/dL" in out
+ assert "80% exhibited" in out
+
+
+def test_safe_streszczenie_html_both_comparison_operators():
+ raw = "moderate (FVIII >= 1%-<= 5%) or mild (FVIII >5%-<40%) haemophilia"
+ out = safe_streszczenie_html(raw)
+ # nic nie ginie, oba operatory uciekają do encji
+ assert ">= 1%-<= 5%" in out
+ assert ">5%-<40%" in out
+ assert "haemophilia" in out
+
+
+def test_safe_streszczenie_html_lt_followed_by_letter_no_data_loss():
+ # "ct" mogłoby zostać zinterpretowane jako z atrybutami;
+ # nie tworzymy pogrubienia ani nie gubimy "and c".
+ raw = "if ad then x TAIL"
+ out = safe_streszczenie_html(raw)
+ assert "" not in out
+ assert "and c" in out
+ assert "TAIL" in out
+
+
+def test_safe_streszczenie_html_strips_jats_keeping_text():
+ raw = "AbstractBACKGROUND: disease."
+ out = safe_streszczenie_html(raw)
+ assert "jats:" not in out
+ assert "Abstract" in out
+ assert "BACKGROUND: disease." in out
+
+
+def test_safe_streszczenie_html_keeps_real_sup_sub():
+ raw = "area 30th percentile CD4+ H2O"
+ out = safe_streszczenie_html(raw)
+ assert "th" in out
+ assert "2" in out
+
+
+def test_safe_streszczenie_html_strips_xss():
+ raw = "Hi
bye"
+ out = safe_streszczenie_html(raw)
+ assert "script" not in out
+ assert "onerror" not in out
+ assert "bye" in out
+
+
+@pytest.mark.parametrize("value", ["", None])
+def test_safe_streszczenie_html_empty(value):
+ assert safe_streszczenie_html(value) == ""
+
+
@pytest.mark.parametrize(
"i, o",
[
diff --git a/src/bpp/util/__init__.py b/src/bpp/util/__init__.py
index b842c412d..e72182380 100644
--- a/src/bpp/util/__init__.py
+++ b/src/bpp/util/__init__.py
@@ -49,6 +49,7 @@
non_url,
safe_html,
safe_html_defaults,
+ safe_streszczenie_html,
slugify_function,
strip_extra_spaces,
strip_extra_spaces_regex,
@@ -111,6 +112,7 @@
"non_url",
"safe_html",
"safe_html_defaults",
+ "safe_streszczenie_html",
"slugify_function",
"strip_extra_spaces",
"strip_extra_spaces_regex",
diff --git a/src/bpp/util/text.py b/src/bpp/util/text.py
index b88bcb54f..2f06e9547 100644
--- a/src/bpp/util/text.py
+++ b/src/bpp/util/text.py
@@ -179,3 +179,68 @@ def safe_html(html):
clean_content_tags=set(),
link_rel=None,
)
+
+
+# Streszczenia (abstrakty) bywają importowane z zewnętrznych źródeł (Crossref,
+# PBN) i mieszają prawdziwy markup (JATS/HTML) z matematycznymi operatorami
+# porównania wpisanymi wprost w tekst ("<30 IU/dL", "ct= 1%").
+# Pojedynczy "goły" '<' bez zamykającego '>' (np. "<30IU/dL ...") sprawia, że
+# parser HTML — a w produkcji minifikator django-minify-html — połyka wszystko
+# aż do następnego '>', niszcząc układ strony (regresja: tekst streszczenia
+# pożerał prawą kolumnę rekordu i całość zlewała się w jedną kolumnę). Dlatego
+# najpierw escape'ujemy gołe nawiasy ostrokątne, a dopiero potem czyścimy resztę
+# sanitizerem nh3.
+#
+# Znacznik uznajemy za poprawny tylko gdy ma nazwę i zamykający '>' bez '<' w
+# środku oraz albo NIE ma atrybutów (````, ````, ``
``), albo
+# zawiera prawdziwy atrybut z '=' (````). Proza typu
+# ```` tych warunków nie spełnia, więc jej '<' trafia do encji zamiast
+# otwierać element (brak fałszywego pogrubienia i utraty tekstu).
+_WELL_FORMED_TAG_RE = re.compile(r"?[a-zA-Z][\w:-]*(?:\s+[^<>]*=[^<>]*)?\s*/?>")
+
+
+def _escape_bare_angle_brackets(text):
+ """Escape '<'/'>' które nie tworzą poprawnego, domkniętego znacznika."""
+ out = []
+ pos = 0
+ for m in _WELL_FORMED_TAG_RE.finditer(text):
+ start, end = m.span()
+ out.append(text[pos:start].replace("<", "<").replace(">", ">"))
+ out.append(text[start:end])
+ pos = end
+ out.append(text[pos:].replace("<", "<").replace(">", ">"))
+ return "".join(out)
+
+
+class safe_streszczenie_defaults:
+ # Te same tagi co `safe_html`, plus sub/sup typowe dla notacji naukowej
+ # (np. "CD4+", "H2O", "m2").
+ ALLOWED_TAGS = safe_html_defaults.ALLOWED_TAGS + ("sub", "sup")
+
+
+def safe_streszczenie_html(html):
+ """Zwróć bezpieczny, zbalansowany HTML streszczenia.
+
+ 1. Escape'uje gołe operatory porównania '<'/'>' z treści streszczenia, aby
+ nie były interpretowane jako początek znacznika (i nie psuły układu
+ strony w minifikatorze).
+ 2. Przepuszcza wynik przez nh3: usuwa niedozwolone tagi (w tym JATS,
+ zachowując ich tekst) oraz potencjalny XSS, oddając poprawny HTML.
+ """
+ html = _escape_bare_angle_brackets(html or "")
+
+ ALLOWED_TAGS = getattr(
+ settings,
+ "STRESZCZENIE_ALLOWED_TAGS",
+ safe_streszczenie_defaults.ALLOWED_TAGS,
+ )
+ ALLOWED_ATTRIBUTES = getattr(
+ settings, "ALLOWED_ATTRIBUTES", safe_html_defaults.ALLOWED_ATTRIBUTES
+ )
+ return nh3.clean(
+ html,
+ tags=set(ALLOWED_TAGS),
+ attributes={k: set(v) for k, v in ALLOWED_ATTRIBUTES.items()},
+ clean_content_tags=set(),
+ link_rel=None,
+ )