From c4bbfd1717fffe7334a0d8d316873b9621b56fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pasternak?= Date: Mon, 1 Jun 2026 14:39:40 +0200 Subject: [PATCH] =?UTF-8?q?fix(browse):=20streszczenia=20z=20operatorami?= =?UTF-8?q?=20<=20>=20nie=20rozbijaj=C4=85=20layoutu=20rekordu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streszczenia importowane z Crossref/PBN zawierają operatory porównania wpisane wprost w tekst (np. "<30 IU/dL", "ct= 1%-<= 5%"). Renderowane przez |safe i przepuszczone przez produkcyjny minifikator HTML (django-minify-html / minify-html) goły "<" bez zamykającego ">" był traktowany jak otwarcie znacznika, który połykał dalszy markup — w tym zamykające

lewej kolumny. Prawa kolumna lądowała wewnątrz lewej, strona zlewała się do jednej kolumny, a tekst zamieniał się w posortowaną, zdeduplikowaną sieczkę słów (atrybuty fałszywego znacznika po reserializacji przez minifikator). Nowy filtr |safe_streszczenie (bpp.util.safe_streszczenie_html): 1. escape'uje gołe operatory < > nie tworzące poprawnego, domkniętego znacznika — bez utraty tekstu (m.in. "ct, linki http) niewprowadzonych tą zmianą; pozostałe hooki (ruff, format) przeszły. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...nie-operatory-porownania-layout.bugfix.rst | 14 ++++ src/bpp/templates/browse/praca_tabela.html | 2 +- .../templates/browse/praca_tabela_mono.html | 2 +- .../templates/browse/praca_tabela_new.html | 2 +- src/bpp/templates/browse/uczelnia.html | 4 +- src/bpp/templatetags/prace.py | 14 ++++ .../test_safe_streszczenie.py | 84 +++++++++++++++++++ src/bpp/tests/test_util.py | 68 +++++++++++++++ src/bpp/util/__init__.py | 2 + src/bpp/util/text.py | 65 ++++++++++++++ 10 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 src/bpp/newsfragments/+streszczenie-operatory-porownania-layout.bugfix.rst create mode 100644 src/bpp/tests/test_templatetags/test_safe_streszczenie.py diff --git a/src/bpp/newsfragments/+streszczenie-operatory-porownania-layout.bugfix.rst b/src/bpp/newsfragments/+streszczenie-operatory-porownania-layout.bugfix.rst new file mode 100644 index 000000000..85e0e7977 --- /dev/null +++ b/src/bpp/newsfragments/+streszczenie-operatory-porownania-layout.bugfix.rst @@ -0,0 +1,14 @@ +Naprawiono rozbijanie dwukolumnowego układu strony rekordu przez +treść streszczenia. Streszczenia importowane z zewnętrznych źródeł +zawierają operatory porównania wpisane wprost w tekst (np. +``<30 IU/dL``, ``ct= 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"]*=[^<>]*)?\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, + )