From c2124ecab161e89da559276d5c31fa3b2dbdb373 Mon Sep 17 00:00:00 2001 From: hcphat Date: Wed, 15 Apr 2026 14:25:53 +0700 Subject: [PATCH 1/4] =?UTF-8?q?ref=202.4.=E7=B5=B1=E5=90=88=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E8=80=85=E3=81=AE=E3=83=86=E3=83=AD=E3=83=83=E3=83=97?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=AB=E3=81=8A=E3=81=91=E3=82=8B=E3=80=81?= =?UTF-8?q?=E8=A4=87=E6=95=B0=E8=A1=8C=E8=A1=A8=E7=A4=BA=E3=81=AE=E6=94=B9?= =?UTF-8?q?=E4=BF=AE:=20Commit=20code=20handle=20multi=20line=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/templates/maintenance/display.html | 2 +- osf/utils/text_rendering.py | 8 +++++ tests/test_text_rendering.py | 38 ++++++++++++++++++++++++ website/templates/nav.mako | 3 +- 4 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 osf/utils/text_rendering.py create mode 100644 tests/test_text_rendering.py diff --git a/admin/templates/maintenance/display.html b/admin/templates/maintenance/display.html index 9d5cde65cd0..a7712caaaaa 100644 --- a/admin/templates/maintenance/display.html +++ b/admin/templates/maintenance/display.html @@ -26,7 +26,7 @@

{% trans "Current alert:" %}

{{ current_alert.start }} - {{ current_alert.end }} UTC {% if current_alert.message %} - {{ current_alert.message }} + {{ current_alert.message|urlize|linebreaksbr }} {% else %} {% trans "The site will undergo maintenance between <localized start time>-<localized end time>. Thank you for your patience." %} {% endif %} diff --git a/osf/utils/text_rendering.py b/osf/utils/text_rendering.py new file mode 100644 index 00000000000..d75138a486a --- /dev/null +++ b/osf/utils/text_rendering.py @@ -0,0 +1,8 @@ +from django.template.defaultfilters import urlize, linebreaksbr + + +def render_text(text: str) -> str: + if not text: + return '' + result = linebreaksbr(urlize(text)) + return result diff --git a/tests/test_text_rendering.py b/tests/test_text_rendering.py new file mode 100644 index 00000000000..3f208e74c47 --- /dev/null +++ b/tests/test_text_rendering.py @@ -0,0 +1,38 @@ +import unittest +from osf.utils.text_rendering import render_text + + +class TestRenderText(unittest.TestCase): + + def test_empty_string(self): + assert render_text('') == '' + + def test_none_input(self): + assert render_text(None) == '' + + def test_linebreaks(self): + text = 'line1\nline2' + result = render_text(text) + + assert '' in result + + def test_url_and_linebreak(self): + text = 'line1\nhttps://abc-test.com' + result = render_text(text) + assert '' not in result + assert 'alert' in result diff --git a/website/templates/nav.mako b/website/templates/nav.mako index 930d51fb970..7204ad5a944 100644 --- a/website/templates/nav.mako +++ b/website/templates/nav.mako @@ -1,4 +1,5 @@ <%def name="nav(service_name, service_url, service_support_url, service_support_target='_self')"> +<% from osf.utils.text_rendering import render_text %>
@@ -139,7 +140,7 @@ ${_('Notice:')} % if maintenance['message']: - ${maintenance['message']} + ${render_text(maintenance['message']) | n} % else: ${_('The site will undergo maintenance between .') | n} ${_("Thank you for your patience.")} From 2e70852b1cb3b7a914236ce78f46ffdbd2f7b6e2 Mon Sep 17 00:00:00 2001 From: ndnhat Date: Thu, 30 Apr 2026 18:18:55 +0700 Subject: [PATCH 2/4] =?UTF-8?q?ref=202.4.=E7=B5=B1=E5=90=88=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E8=80=85=E3=81=AE=E3=83=86=E3=83=AD=E3=83=83=E3=83=97?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=AB=E3=81=8A=E3=81=91=E3=82=8B=E3=80=81?= =?UTF-8?q?=E8=A4=87=E6=95=B0=E8=A1=8C=E8=A1=A8=E7=A4=BA=E3=81=AE=E6=94=B9?= =?UTF-8?q?=E4=BF=AE:=20Fix=20bug=20IT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/base/templatetags/filters.py | 5 +++++ admin/templates/maintenance/display.html | 4 ++-- osf/utils/text_rendering.py | 18 ++++++++++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/admin/base/templatetags/filters.py b/admin/base/templatetags/filters.py index 5821d331a76..40f4ae805e5 100644 --- a/admin/base/templatetags/filters.py +++ b/admin/base/templatetags/filters.py @@ -3,6 +3,7 @@ from django.utils.safestring import mark_safe import json from django.utils.translation import ugettext_lazy as _ +from osf.utils.text_rendering import osf_urlize as custom_osf_urlize register = template.Library() @@ -13,3 +14,7 @@ def jsonify(o): @register.filter def transValue(value1): return _(str(value1)) + +@register.filter +def osf_urlize(text): + return custom_osf_urlize(text) diff --git a/admin/templates/maintenance/display.html b/admin/templates/maintenance/display.html index a7712caaaaa..2f278c63dde 100644 --- a/admin/templates/maintenance/display.html +++ b/admin/templates/maintenance/display.html @@ -3,7 +3,7 @@ {% load render_bundle from webpack_loader %} {% load spam_extras %} {% load static %} -{% load render_bundle from webpack_loader %} +{% load filters %} {% block title %} {% trans "Maintenance State" %} {% endblock title %} @@ -26,7 +26,7 @@

{% trans "Current alert:" %}

{{ current_alert.start }} - {{ current_alert.end }} UTC {% if current_alert.message %} - {{ current_alert.message|urlize|linebreaksbr }} + {{ current_alert.message|osf_urlize|linebreaksbr }} {% else %} {% trans "The site will undergo maintenance between <localized start time>-<localized end time>. Thank you for your patience." %} {% endif %} diff --git a/osf/utils/text_rendering.py b/osf/utils/text_rendering.py index d75138a486a..ed7701c0b0e 100644 --- a/osf/utils/text_rendering.py +++ b/osf/utils/text_rendering.py @@ -1,8 +1,22 @@ -from django.template.defaultfilters import urlize, linebreaksbr +from django.template.defaultfilters import linebreaksbr +from django.utils.html import escape +from django.utils.safestring import mark_safe +import re +URL_RE = re.compile(r'(?P(?:https?://[^\s<>\"\'【(「『〔】)」』〕、。?]+|(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,})(?:[^\s<>\"\'【(「『〔】)」』〕、。]*[^\s<>\"\'.,!?\)\]}】)」』〕、。])?)') + +def osf_urlize(text: str) -> str: + if not text: + return '' + text = escape(text) + def replace(match): + url = match.group('url') + href = url if url.startswith('http') else 'http://' + url + return f'{url}' + return mark_safe(URL_RE.sub(replace, text)) def render_text(text: str) -> str: if not text: return '' - result = linebreaksbr(urlize(text)) + result = linebreaksbr(osf_urlize(text)) return result From 3e0b44addc624bb3a0b0a0898fb406c7c78f6c2e Mon Sep 17 00:00:00 2001 From: ndnhat Date: Mon, 4 May 2026 09:30:42 +0700 Subject: [PATCH 3/4] =?UTF-8?q?ref=202.4.=E7=B5=B1=E5=90=88=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E8=80=85=E3=81=AE=E3=83=86=E3=83=AD=E3=83=83=E3=83=97?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=AB=E3=81=8A=E3=81=91=E3=82=8B=E3=80=81?= =?UTF-8?q?=E8=A4=87=E6=95=B0=E8=A1=8C=E8=A1=A8=E7=A4=BA=E3=81=AE=E6=94=B9?= =?UTF-8?q?=E4=BF=AE:=20fix=20bug=20when=20url=20inside=202bytes=20bracket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- osf/utils/text_rendering.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/osf/utils/text_rendering.py b/osf/utils/text_rendering.py index ed7701c0b0e..7b1562044bd 100644 --- a/osf/utils/text_rendering.py +++ b/osf/utils/text_rendering.py @@ -3,15 +3,45 @@ from django.utils.safestring import mark_safe import re +from urllib.parse import urlparse + URL_RE = re.compile(r'(?P(?:https?://[^\s<>\"\'【(「『〔】)」』〕、。?]+|(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,})(?:[^\s<>\"\'【(「『〔】)」』〕、。]*[^\s<>\"\'.,!?\)\]}】)」』〕、。])?)') +def is_valid_domain(host: str) -> bool: + if not host: + return False + if not re.match(r'^[a-zA-Z0-9.-]+$', host): + return False + if '..' in host or '.' not in host: + return False + + labels = host.split('.') + for label in labels: + if label.startswith('-') or label.endswith('-') or not label: + return False + + tld = labels[-1] + if not re.match(r'^[a-zA-Z]{2,}$', tld): + return False + + return True + def osf_urlize(text: str) -> str: if not text: return '' text = escape(text) + def replace(match): url = match.group('url') href = url if url.startswith('http') else 'http://' + url + try: + host = urlparse(href).hostname + except Exception: + host = None + + if not host or not is_valid_domain(host): + return url + return f'{url}' return mark_safe(URL_RE.sub(replace, text)) From 17418907b0b76d6c20df8912d85b27d75a411ed5 Mon Sep 17 00:00:00 2001 From: ndnhat Date: Mon, 4 May 2026 18:46:26 +0700 Subject: [PATCH 4/4] =?UTF-8?q?ref=202.4.=E7=B5=B1=E5=90=88=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E8=80=85=E3=81=AE=E3=83=86=E3=83=AD=E3=83=83=E3=83=97?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=AB=E3=81=8A=E3=81=91=E3=82=8B=E3=80=81?= =?UTF-8?q?=E8=A4=87=E6=95=B0=E8=A1=8C=E8=A1=A8=E7=A4=BA=E3=81=AE=E6=94=B9?= =?UTF-8?q?=E4=BF=AE:=20Fix=20IT:=20The=20URL=20must=20start=20with=20http?= =?UTF-8?q?=20or=20https?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- osf/utils/text_rendering.py | 93 ++++++++++++++++++++++++++------ tests/test_text_rendering.py | 100 ++++++++++++++++++++++++++++------- 2 files changed, 158 insertions(+), 35 deletions(-) diff --git a/osf/utils/text_rendering.py b/osf/utils/text_rendering.py index 7b1562044bd..ffa2bed0327 100644 --- a/osf/utils/text_rendering.py +++ b/osf/utils/text_rendering.py @@ -2,10 +2,15 @@ from django.utils.html import escape from django.utils.safestring import mark_safe import re - from urllib.parse import urlparse -URL_RE = re.compile(r'(?P(?:https?://[^\s<>\"\'【(「『〔】)」』〕、。?]+|(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z]{2,})(?:[^\s<>\"\'【(「『〔】)」』〕、。]*[^\s<>\"\'.,!?\)\]}】)」』〕、。])?)') +_LEADING_BRACKETS = frozenset('([{【(「『〔') +_CLOSER_MAP = {')': '(', ']': '[', '}': '{', ')': '(', '】': '【', '」': '「', '』': '『', '〕': '〔'} +_TRAILING_PUNCT = frozenset('.,!?、。') + +# Matches URL portion only — stops at whitespace, HTML chars, and Japanese brackets +_URL_IN_TEXT = re.compile(r'https?://[^\s<>"\'【(「『〔】)」』〕、。]*') + def is_valid_domain(host: str) -> bool: if not host: @@ -26,27 +31,83 @@ def is_valid_domain(host: str) -> bool: return True + +def _trim_edges(s: str): + core = s + leading = '' + trailing = '' + + while core and core[0] in _LEADING_BRACKETS: + leading += core[0] + core = core[1:] + + changed = True + while changed and core: + changed = False + if core[-1] in _TRAILING_PUNCT: + trailing = core[-1] + trailing + core = core[:-1] + changed = True + elif core[-1] in _CLOSER_MAP: + closer = core[-1] + opener = _CLOSER_MAP[closer] + if core.count(opener) < core.count(closer): + trailing = closer + trailing + core = core[:-1] + changed = True + + return leading, core, trailing + + def osf_urlize(text: str) -> str: if not text: return '' - text = escape(text) - def replace(match): - url = match.group('url') - href = url if url.startswith('http') else 'http://' + url - try: - host = urlparse(href).hostname - except Exception: - host = None + result = [] + for part in re.split(r'(\s+)', text): + if not part: + continue + if re.match(r'^\s+$', part): + result.append(part) + continue + + for chunk in re.split(r'([<>"])', part): + if not chunk: + continue + if chunk in ('<', '>', '"'): + result.append(escape(chunk)) + continue + + leading, core, trailing = _trim_edges(chunk) + + m = _URL_IN_TEXT.search(core) + if m: + text_before = core[:m.start()] + url_raw = m.group(0) + text_after = core[m.start() + len(url_raw):] + + # Strip trailing ASCII punctuation and unbalanced brackets from URL + _, url_core, url_trailing = _trim_edges(url_raw) + text_after = url_trailing + text_after + + try: + host = urlparse(url_core).hostname + if host and is_valid_domain(host): + result.append( + escape(leading + text_before) + + f'{escape(url_core)}' + + escape(text_after + trailing) + ) + continue + except Exception: + pass + + result.append(escape(leading + core + trailing)) - if not host or not is_valid_domain(host): - return url + return mark_safe(''.join(result)) - return f'{url}' - return mark_safe(URL_RE.sub(replace, text)) def render_text(text: str) -> str: if not text: return '' - result = linebreaksbr(osf_urlize(text)) - return result + return linebreaksbr(osf_urlize(text)) diff --git a/tests/test_text_rendering.py b/tests/test_text_rendering.py index 3f208e74c47..0474c279ea2 100644 --- a/tests/test_text_rendering.py +++ b/tests/test_text_rendering.py @@ -1,5 +1,73 @@ import unittest -from osf.utils.text_rendering import render_text +from osf.utils.text_rendering import render_text, osf_urlize + + +class TestOsfUrlize(unittest.TestCase): + + def test_https_url_linkified(self): + result = osf_urlize('Visit https://example.com') + assert 'alert("xss")') + assert '' - result = render_text(text) + result = render_text('') assert '