From a4f13229d8fde243fca2c92b657bb957462cdbd5 Mon Sep 17 00:00:00 2001 From: Federico Vancini Date: Fri, 24 Jul 2026 16:15:13 +0200 Subject: [PATCH 1/3] redirect fix --- setup.py | 1 + src/rer/linkmap/browser/configure.zcml | 2 + src/rer/linkmap/browser/views.py | 54 +++++++------- src/rer/linkmap/controlpanels/settings.py | 4 +- src/rer/linkmap/tests/test_linkmap_views.py | 79 +++++++++++++-------- 5 files changed, 81 insertions(+), 59 deletions(-) diff --git a/setup.py b/setup.py index 02ff8f9..ca14707 100644 --- a/setup.py +++ b/setup.py @@ -55,6 +55,7 @@ "z3c.jbot", "plone.api>=1.8.4", "plone.app.dexterity", + "lxml", ], extras_require={ "test": [ diff --git a/src/rer/linkmap/browser/configure.zcml b/src/rer/linkmap/browser/configure.zcml index e895ab3..62d79f4 100644 --- a/src/rer/linkmap/browser/configure.zcml +++ b/src/rer/linkmap/browser/configure.zcml @@ -9,6 +9,7 @@ for="Products.CMFPlone.interfaces.IPloneSiteRoot" class=".views.ATMapJSONView" permission="zope2.View" + layer="rer.linkmap.interfaces.IBrowserLayer" /> diff --git a/src/rer/linkmap/browser/views.py b/src/rer/linkmap/browser/views.py index b7f698a..25111e5 100644 --- a/src/rer/linkmap/browser/views.py +++ b/src/rer/linkmap/browser/views.py @@ -1,5 +1,6 @@ from Acquisition import aq_base from json import dumps +from lxml import etree from plone import api from Products.Five import BrowserView from rer.linkmap.linkmap import CATEGORY_C1 @@ -8,7 +9,6 @@ from rer.linkmap.linkmap import is_valid_date from rer.linkmap.linkmap import is_valid_url from rer.linkmap.linkmap import today_date_string -from xml.sax.saxutils import escape from zExceptions import NotFound REGISTRY_PREFIX = "rer.linkmap.controlpanels.settings.ILinkMapSettings" @@ -24,11 +24,11 @@ def get_registry_value(field_name, default=""): def get_expose_json(): - return get_registry_value("expose_json", default=True) + return get_registry_value("expose_json", default=False) def get_expose_xml(): - return get_registry_value("expose_xml", default=True) + return get_registry_value("expose_xml", default=False) def get_data_ultima_modifica(): @@ -60,58 +60,58 @@ def build_payload(): return payload -def build_empty_xml(): - return ( - '\n' - f'' - ) +def _qname(tag): + return f"{{{NAMESPACE}}}{tag}" + + +def _serialize_xml(root): + return etree.tostring( + root, xml_declaration=True, encoding="utf-8", pretty_print=True + ).decode("utf-8") def build_xml(payload): - root_open = f'' - lines = [ - '', - root_open, - f" {escape(payload['data_ultima_modifica'])}" - "", - " ", - ] + root = etree.Element(_qname("amministrazione_trasparente"), nsmap={None: NAMESPACE}) - category = CATEGORY_C1 + data_node = etree.SubElement(root, _qname("data_ultima_modifica")) + data_node.text = payload["data_ultima_modifica"] - lines.append(f" <{category}>") + map_node = etree.SubElement(root, _qname("map")) + + category = CATEGORY_C1 + category_node = etree.SubElement(map_node, _qname(category)) map_values = payload.get(category, {}) for map_key in CATEGORY_KEYS: map_url = map_values.get(map_key) if not map_url: continue - lines.append(f" <{map_key}>{escape(map_url)}") - lines.append(f" ") - lines.extend([" ", ""]) - return "\n".join(lines) + key_node = etree.SubElement(category_node, _qname(map_key)) + key_node.text = map_url + + return _serialize_xml(root) class ATMapJSONView(BrowserView): def __call__(self): if aq_base(self.context) is not aq_base(api.portal.get()): raise NotFound() + if not get_expose_json(): + raise NotFound() self.request.response.setHeader( "Content-Type", "application/json; charset=utf-8" ) - if not get_expose_json(): - return "{}" payload = build_payload() - return dumps(payload, indent=2, ensure_ascii=False, sort_keys=True) + return dumps(payload, indent=2, ensure_ascii=False) class ATMapXMLView(BrowserView): def __call__(self): if aq_base(self.context) is not aq_base(api.portal.get()): raise NotFound() + if not get_expose_xml(): + raise NotFound() self.request.response.setHeader( "Content-Type", "application/xml; charset=utf-8" ) - if not get_expose_xml(): - return build_empty_xml() payload = build_payload() return build_xml(payload) diff --git a/src/rer/linkmap/controlpanels/settings.py b/src/rer/linkmap/controlpanels/settings.py index e672d60..9be611a 100644 --- a/src/rer/linkmap/controlpanels/settings.py +++ b/src/rer/linkmap/controlpanels/settings.py @@ -33,7 +33,7 @@ class ILinkMapSettings(Interface): "formato JSON.", ), required=False, - default=True, + default=False, ) expose_xml = Bool( @@ -44,7 +44,7 @@ class ILinkMapSettings(Interface): "formato XML.", ), required=False, - default=True, + default=False, ) data_ultima_modifica = TextLine( diff --git a/src/rer/linkmap/tests/test_linkmap_views.py b/src/rer/linkmap/tests/test_linkmap_views.py index 7ab3b77..b13a9cf 100644 --- a/src/rer/linkmap/tests/test_linkmap_views.py +++ b/src/rer/linkmap/tests/test_linkmap_views.py @@ -5,8 +5,11 @@ from plone.app.testing import setRoles from plone.app.testing import TEST_USER_ID from rer.linkmap.controlpanels.settings import ILinkMapSettings +from rer.linkmap.interfaces import IBrowserLayer from rer.linkmap.testing import RER_LINKMAP_INTEGRATION_TESTING # noqa: E501 from zExceptions import NotFound +from zope.interface import alsoProvides +from zope.interface import noLongerProvides import unittest @@ -63,9 +66,8 @@ def test_json_view_returns_schema_shape(self): "https://www.example.org/disposizioni-generali" ) - def test_json_view_disabled_returns_blank_object(self): - """Test that JSON view returns an empty object when expose_json is - False, as if the system was not configured at all.""" + def test_json_view_disabled_returns_404(self): + """Test that JSON view raises NotFound when expose_json is False.""" api.portal.set_registry_record( "expose_json", False, @@ -77,16 +79,14 @@ def test_json_view_disabled_returns_blank_object(self): interface=ILinkMapSettings, ) - output = api.content.get_view( + view = api.content.get_view( name="at_map.json", context=self.portal, request=self.request - )() - - assert "application/json" in self.request.RESPONSE.getHeader("Content-Type") - assert output == "{}" + ) + with self.assertRaises(NotFound): + view() - def test_xml_view_disabled_returns_blank_object(self): - """Test that XML view returns an empty root element when - expose_xml is False, as if the system was not configured at all.""" + def test_xml_view_disabled_returns_404(self): + """Test that XML view raises NotFound when expose_xml is False.""" api.portal.set_registry_record( "expose_xml", False, @@ -98,17 +98,11 @@ def test_xml_view_disabled_returns_blank_object(self): interface=ILinkMapSettings, ) - output = api.content.get_view( + view = api.content.get_view( name="at_map.xml", context=self.portal, request=self.request - )() - - assert "application/xml" in self.request.RESPONSE.getHeader("Content-Type") - root = safe_fromstring(output) - assert ( - root.tag - == "{https://guida-servizi.anticorruzione.it/trasparenza}amministrazione_trasparente" ) - assert len(root) == 0 + with self.assertRaises(NotFound): + view() def test_json_view_ensures_required_root_key(self): """Test that JSON view adds required amministrazione_trasparente key.""" @@ -219,6 +213,28 @@ def test_xml_view_not_accessible_from_non_root_context(self): with self.assertRaises((NotFound, AttributeError)): page.restrictedTraverse("@@at_map.xml")() + def test_json_view_not_registered_without_browser_layer(self): + """Test that at_map.json is only registered while the add-on's + browser layer is applied to the request, i.e. while the add-on + is installed.""" + noLongerProvides(self.request, IBrowserLayer) + try: + with self.assertRaises((NotFound, AttributeError)): + self.portal.restrictedTraverse("@@at_map.json")() + finally: + alsoProvides(self.request, IBrowserLayer) + + def test_xml_view_not_registered_without_browser_layer(self): + """Test that at_map.xml is only registered while the add-on's + browser layer is applied to the request, i.e. while the add-on + is installed.""" + noLongerProvides(self.request, IBrowserLayer) + try: + with self.assertRaises((NotFound, AttributeError)): + self.portal.restrictedTraverse("@@at_map.xml")() + finally: + alsoProvides(self.request, IBrowserLayer) + def test_json_view_with_multiple_fields(self): """Test JSON view with multiple category fields.""" api.portal.set_registry_record( @@ -304,22 +320,25 @@ def test_empty_fields_not_included_in_output(self): assert "amministrazione_trasparente" in c1 assert "disposizioni_generali" not in c1 - def test_default_expose_flags_are_true(self): - """Test that expose_json and expose_xml default to True.""" - # Don't explicitly set the flags, verify they default to True + def test_default_expose_flags_are_false(self): + """Test that expose_json and expose_xml default to False, i.e. the + views are not exposed unless explicitly enabled.""" + # Don't explicitly set the flags, verify they default to False api.portal.set_registry_record( "amministrazione_trasparente", "https://example.org/at", interface=ILinkMapSettings, ) - # Both views should work with defaults - json_output = api.content.get_view( + # Both views should raise NotFound with defaults + json_view = api.content.get_view( name="at_map.json", context=self.portal, request=self.request - )() - xml_output = api.content.get_view( + ) + xml_view = api.content.get_view( name="at_map.xml", context=self.portal, request=self.request - )() + ) - assert loads(json_output) - assert "amministrazione_trasparente" in xml_output + with self.assertRaises(NotFound): + json_view() + with self.assertRaises(NotFound): + xml_view() From 6a35975fd4a78d67c9e9590cdb2949c9031aea04 Mon Sep 17 00:00:00 2001 From: Federico Vancini Date: Wed, 29 Jul 2026 12:05:50 +0200 Subject: [PATCH 2/3] update changelog --- CHANGES.rst | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 670d60d..1ed16c0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,14 @@ Changelog 1.0.10 (unreleased) ------------------- -- Nothing changed yet. +- Use lxml for the xml generation. + [fedevancin] +- at_map views are now disabled by default in the controlpanel. + [fedevancin] +- Added the layer to the at_map views. + [fedevancin] +- Return NotFound when views are disabled, instead of empty objects. + [fedevancin] 1.0.9 (2026-07-23) From a7e42c61ecd30844ec1ab1e615f404ce995b1a1d Mon Sep 17 00:00:00 2001 From: Federico Vancini Date: Wed, 26 Aug 2026 15:02:53 +0200 Subject: [PATCH 3/3] fix internal links backend domain bug --- CHANGES.rst | 3 ++ src/rer/linkmap/browser/views.py | 70 ++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 1ed16c0..0d4afcc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,6 +13,9 @@ Changelog [fedevancin] - Return NotFound when views are disabled, instead of empty objects. [fedevancin] +- Internal links in the category fields now always use the public frontend + domain instead of the backend one. + [fedevancin] 1.0.9 (2026-07-23) diff --git a/src/rer/linkmap/browser/views.py b/src/rer/linkmap/browser/views.py index 25111e5..1ce04c9 100644 --- a/src/rer/linkmap/browser/views.py +++ b/src/rer/linkmap/browser/views.py @@ -1,7 +1,9 @@ +from AccessControl import Unauthorized from Acquisition import aq_base from json import dumps from lxml import etree from plone import api +from plone.registry.interfaces import IRegistry from Products.Five import BrowserView from rer.linkmap.linkmap import CATEGORY_C1 from rer.linkmap.linkmap import CATEGORY_KEYS @@ -9,7 +11,17 @@ from rer.linkmap.linkmap import is_valid_date from rer.linkmap.linkmap import is_valid_url from rer.linkmap.linkmap import today_date_string +from urllib.parse import urlsplit +from urllib.parse import urlunsplit from zExceptions import NotFound +from zope.component import getUtility + +try: + from plone.volto.interfaces import IVoltoSettings + + HAS_PLONE_VOLTO = True +except ImportError: + HAS_PLONE_VOLTO = False REGISTRY_PREFIX = "rer.linkmap.controlpanels.settings.ILinkMapSettings" ROOT_KEY = "amministrazione_trasparente" @@ -38,20 +50,70 @@ def get_data_ultima_modifica(): return today_date_string() -def build_category_map_from_fields(): +def get_frontend_url(): + """Return the public (frontend) base url for this Plone site. + + Editors fill in the category fields as plain absolute urls, and they + often copy them while browsing the backend, so internal links may end + up with the backend/IAM domain baked in. Falls back to the current + portal absolute_url() when plone.volto is not installed or + ``volto.frontend_domain`` has not been configured. + """ + portal_url = api.portal.get().absolute_url() + if not HAS_PLONE_VOLTO: + return portal_url + registry = getUtility(IRegistry) + settings = registry.forInterface(IVoltoSettings, prefix="volto", check=False) + frontend_domain = (getattr(settings, "frontend_domain", "") or "").rstrip("/") + if not frontend_domain or frontend_domain == "http://localhost:3000": + return portal_url + return frontend_domain + + +def resolve_internal_url(value, frontend_url): + """If ``value`` points to an object inside this Plone site, rewrite it + so that it always uses the public frontend domain, regardless of the + domain that was used to author it (e.g. the backend/IAM domain). + External urls are returned unchanged. + """ + parsed = urlsplit(value) + path = parsed.path.strip("/") + if not path: + return value + portal = api.portal.get() + try: + target = portal.unrestrictedTraverse(path, None) + except (AttributeError, KeyError, TypeError, ValueError, Unauthorized): + target = None + if target is None: + return value + frontend_parts = urlsplit(frontend_url) + return urlunsplit( + ( + frontend_parts.scheme, + frontend_parts.netloc, + parsed.path, + parsed.query, + parsed.fragment, + ) + ) + + +def build_category_map_from_fields(frontend_url): """Build category map from individual field values.""" category_map = {} for key in CATEGORY_KEYS: value = get_registry_value(key) if value and is_valid_url(value.strip()): - category_map[key] = value.strip() + category_map[key] = resolve_internal_url(value.strip(), frontend_url) return category_map def build_payload(): data_ultima_modifica = get_data_ultima_modifica() - category_map = build_category_map_from_fields() - ensure_required_root_url(category_map, api.portal.get().absolute_url()) + frontend_url = get_frontend_url() + category_map = build_category_map_from_fields(frontend_url) + ensure_required_root_url(category_map, frontend_url) payload = { "data_ultima_modifica": data_ultima_modifica,