Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<or ≥15K``, ``>= 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.
2 changes: 1 addition & 1 deletion src/bpp/templates/browse/praca_tabela.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
<th>Streszczenie:</th>
<td>{% for streszczenie in praca.streszczenia.all %}
<!-- <strong>{{streszczenie.jezyk_streszczenia.nazwa}}</strong> -->
<p>{{ streszczenie.streszczenie|safe }}</p>
<p>{{ streszczenie.streszczenie|safe_streszczenie }}</p>
{% endfor %}
</td>
</tr>
Expand Down
2 changes: 1 addition & 1 deletion src/bpp/templates/browse/praca_tabela_mono.html
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ <h3 class="praca-mono__card-title">
{{ streszczenie.jezyk_streszczenia.nazwa }}:
</strong>
{% endif %}
<p class="praca-mono__abstract-text">{{ streszczenie.streszczenie|safe }}</p>
<p class="praca-mono__abstract-text">{{ streszczenie.streszczenie|safe_streszczenie }}</p>
</div>
{% endfor %}
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/bpp/templates/browse/praca_tabela_new.html
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ <h3 class="praca-detail-new__card-title">
{{ streszczenie.jezyk_streszczenia.nazwa }}:
</strong>
{% endif %}
<p class="praca-detail-new__abstract-text">{{ streszczenie.streszczenie|safe }}</p>
<p class="praca-detail-new__abstract-text">{{ streszczenie.streszczenie|safe_streszczenie }}</p>
</div>
{% endfor %}
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/bpp/templates/browse/uczelnia.html
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -821,7 +821,7 @@ <h3 class="section-title uczelnia__section-title--margin-top">
{{ rekord.rekord.opis_bibliograficzny_cache|safe }}
</a>
<div class="abstract-content">
{{ rekord.streszczenie|truncatewords_html:130|safe }}
{{ rekord.streszczenie|safe_streszczenie|truncatewords_html:130|safe }}
</div>
<div class="abstract-meta">
{{ rekord.ostatnio_zmieniony|date:"d.m.Y H:i" }}
Expand Down
14 changes: 14 additions & 0 deletions src/bpp/templatetags/prace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
84 changes: 84 additions & 0 deletions src/bpp/tests/test_templatetags/test_safe_streszczenie.py
Original file line number Diff line number Diff line change
@@ -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 ``</p></div>``) — 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 %}"
"<div class='columns'>"
"<div class='left-column'><p class='abstract'>"
"{{ abstract|FILTER }}"
"</p></div>"
"<div class='right-column'>RIGHT_COLUMN_MARKER</div>"
"</div>"
)


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(
"<!DOCTYPE html><html><body>" + rendered + "</body></html>",
**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 "&lt;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()
68 changes: 68 additions & 0 deletions src/bpp/tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from bpp.util import (
fulltext_tokenize,
knapsack,
safe_streszczenie_html,
strip_html,
strip_nonalphanumeric,
wytnij_isbn_z_uwag,
Expand Down Expand Up @@ -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 "&lt;30IU/dL" in out
assert "&lt;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 "&gt;= 1%-&lt;= 5%" in out
assert "&gt;5%-&lt;40%" in out
assert "haemophilia" in out


def test_safe_streszczenie_html_lt_followed_by_letter_no_data_loss():
# "ct<or ≥15K" -- '<' tuż przy literze wygląda jak znacznik, ale jest
# niedomknięty; nie wolno połknąć dalszego tekstu.
raw = "baseline plt ct<or ≥15K/μL. Age (< or ≥65 y) TAIL"
out = safe_streszczenie_html(raw)
assert "ct&lt;or" in out
assert "TAIL" in out


def test_safe_streszczenie_html_prose_resembling_tag_no_data_loss():
# "<b and c>" mogłoby zostać zinterpretowane jako <b> z atrybutami;
# nie tworzymy pogrubienia ani nie gubimy "and c".
raw = "if a<b and c>d then x TAIL"
out = safe_streszczenie_html(raw)
assert "<b>" not in out
assert "and c" in out
assert "TAIL" in out


def test_safe_streszczenie_html_strips_jats_keeping_text():
raw = "<jats:title>Abstract</jats:title><jats:p>BACKGROUND: disease.</jats:p>"
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 30<sup>th</sup> percentile CD4<sup>+</sup> H<sub>2</sub>O"
out = safe_streszczenie_html(raw)
assert "<sup>th</sup>" in out
assert "<sub>2</sub>" in out


def test_safe_streszczenie_html_strips_xss():
raw = "Hi <script>alert(1)</script> <img src=x onerror=alert(2)> 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",
[
Expand Down
2 changes: 2 additions & 0 deletions src/bpp/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
non_url,
safe_html,
safe_html_defaults,
safe_streszczenie_html,
slugify_function,
strip_extra_spaces,
strip_extra_spaces_regex,
Expand Down Expand Up @@ -111,6 +112,7 @@
"non_url",
"safe_html",
"safe_html_defaults",
"safe_streszczenie_html",
"slugify_function",
"strip_extra_spaces",
"strip_extra_spaces_regex",
Expand Down
65 changes: 65 additions & 0 deletions src/bpp/util/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<or ≥15K", ">= 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 (``<sup>``, ``</sup>``, ``<br/>``), albo
# zawiera prawdziwy atrybut z '=' (``<jats:italic toggle="yes">``). Proza typu
# ``<b and c>`` 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("<", "&lt;").replace(">", "&gt;"))
out.append(text[start:end])
pos = end
out.append(text[pos:].replace("<", "&lt;").replace(">", "&gt;"))
return "".join(out)


class safe_streszczenie_defaults:
# Te same tagi co `safe_html`, plus sub/sup typowe dla notacji naukowej
# (np. "CD4<sup>+</sup>", "H<sub>2</sub>O", "m<sup>2</sup>").
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,
)
Loading