From 72d3e07239cb4e53d7f1ee4b13663a372a2f59f7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9rome=20Perrin?= <jerome@nexedi.com>
Date: Fri, 12 Apr 2024 14:19:26 +0900
Subject: [PATCH] ZMI: re-implement the logic to prepend authentication path

The previous approach from #1196 was not correct when using virtual host,
because AUTHENTICATION_PATH is not usable in virtual host contexts.

This uses a different approach, by making the js and css path
subscribers take care of generating the URLs with the authentication
path prepended using request API aware of virtual hosting.
---
 src/App/dtml/manage_page_header.dtml |  20 +++--
 src/zmi/styles/subscriber.py         |  51 +++++++++++--
 src/zmi/styles/tests.py              | 105 ++++++++++++++++++++++++---
 3 files changed, 149 insertions(+), 27 deletions(-)

diff --git a/src/App/dtml/manage_page_header.dtml b/src/App/dtml/manage_page_header.dtml
index e8b8659e10..4845d45d44 100644
--- a/src/App/dtml/manage_page_header.dtml
+++ b/src/App/dtml/manage_page_header.dtml
@@ -10,27 +10,25 @@
 </dtml-let>
 
 <title><dtml-if title_or_id><dtml-var title_or_id><dtml-else>Zope</dtml-if></title>
-<dtml-let basepath="'/'.join([''] + [p for p in (REQUEST['BASEPATH1'], REQUEST.get('AUTHENTICATION_PATH')) if p])">
 
 <dtml-in css_urls>
-	<link rel="stylesheet" type="text/css" href="&dtml-basepath;&dtml-sequence-item;" />
+	<link rel="stylesheet" type="text/css" href="&dtml-sequence-item;" />
 </dtml-in>
 <dtml-in js_urls>
-	<script src="&dtml-basepath;&dtml-sequence-item;"></script>
+	<script src="&dtml-sequence-item;"></script>
 </dtml-in>
 
-<link rel="shortcut icon" type="image/x-icon" href="&dtml-basepath;/++resource++zmi/logo/favicon/favicon.ico" />
-<link rel="apple-touch-icon" sizes="180x180" href="&dtml-basepath;/++resource++zmi/logo/favicon/apple-touch-icon.png" />
-<link rel="icon" type="image/png" sizes="32x32" href="&dtml-basepath;/++resource++zmi/logo/favicon/favicon-32x32.png" />
-<link rel="icon" type="image/png" sizes="16x16" href="&dtml-basepath;/++resource++zmi/logo/favicon/favicon-16x16.png" />
-<link rel="manifest" href="&dtml-basepath;/++resource++zmi/logo/favicon/site.webmanifest" />
-<link rel="mask-icon" href="&dtml-basepath;/++resource++zmi/logo/favicon/safari-pinned-tab.svg" color="#5bbad5" />
-<meta name="msapplication-config" content="&dtml-basepath;/++resource++zmi/logo/favicon/browserconfig.xml"/>
+<link rel="shortcut icon" type="image/x-icon" href="&dtml-BASEPATH1;/++resource++zmi/logo/favicon/favicon.ico" />
+<link rel="apple-touch-icon" sizes="180x180" href="&dtml-BASEPATH1;/++resource++zmi/logo/favicon/apple-touch-icon.png" />
+<link rel="icon" type="image/png" sizes="32x32" href="&dtml-BASEPATH1;/++resource++zmi/logo/favicon/favicon-32x32.png" />
+<link rel="icon" type="image/png" sizes="16x16" href="&dtml-BASEPATH1;/++resource++zmi/logo/favicon/favicon-16x16.png" />
+<link rel="manifest" href="&dtml-BASEPATH1;/++resource++zmi/logo/favicon/site.webmanifest" />
+<link rel="mask-icon" href="&dtml-BASEPATH1;/++resource++zmi/logo/favicon/safari-pinned-tab.svg" color="#5bbad5" />
+<meta name="msapplication-config" content="&dtml-BASEPATH1;/++resource++zmi/logo/favicon/browserconfig.xml"/>
 <meta name="msapplication-TileColor" content="#2d89ef" />
 <meta name="theme-color" content="#ffffff" />
 
 </head>
-</dtml-let>
 <!-- REFACT what is a better way to get the last part of the current URL? -->
 <body id="nodeid-<dtml-var "getId()">" class="zmi zmi-<dtml-var "this().meta_type.replace(' ', '-').replace('(', '').replace(')', '')"> zmi-<dtml-var "URL0[_.len(URL1)+1:]">">
 </dtml-unless>
diff --git a/src/zmi/styles/subscriber.py b/src/zmi/styles/subscriber.py
index 3320a3c6de..0f3acf7f36 100644
--- a/src/zmi/styles/subscriber.py
+++ b/src/zmi/styles/subscriber.py
@@ -1,14 +1,48 @@
+import itertools
+
 import zope.component
 import zope.interface
+from Acquisition import aq_parent
+from ZPublisher.HTTPRequest import HTTPRequest
+
+
+def prepend_authentication_path(request: HTTPRequest, path: str) -> str:
+    """Prepend the path of the user folder.
+
+    Because ++resource++zmi is protected, we generate URLs relative to the
+    user folder of the logged-in user, so that the user can access the
+    resources.
+    """
+    authenticated_user = request.get("AUTHENTICATED_USER")
+    if not authenticated_user:
+        return path
+
+    # prepend the authentication path, unless it is already part of the
+    # virtual host root.
+    authentication_path = []
+    ufpp = aq_parent(aq_parent(authenticated_user)).getPhysicalPath()
+    vrpp = request.get("VirtualRootPhysicalPath") or ()
+    for ufp, vrp in itertools.zip_longest(ufpp, vrpp):
+        if ufp == vrp:
+            continue
+        authentication_path.append(ufp)
+
+    parts = [
+        p for p in itertools.chain(authentication_path, path.split("/")) if p]
+
+    return request.physicalPathToURL(parts, relative=True)
 
 
 @zope.component.adapter(zope.interface.Interface)
 def css_paths(context):
     """Return paths to CSS files needed for the Zope 4 ZMI."""
     return (
-        '/++resource++zmi/bootstrap-4.6.0/bootstrap.min.css',
-        '/++resource++zmi/fontawesome-free-5.15.2/css/all.css',
-        '/++resource++zmi/zmi_base.css',
+        prepend_authentication_path(context.REQUEST, path)
+        for path in (
+            '/++resource++zmi/bootstrap-4.6.0/bootstrap.min.css',
+            '/++resource++zmi/fontawesome-free-5.15.2/css/all.css',
+            '/++resource++zmi/zmi_base.css',
+        )
     )
 
 
@@ -16,8 +50,11 @@ def css_paths(context):
 def js_paths(context):
     """Return paths to JS files needed for the Zope 4 ZMI."""
     return (
-        '/++resource++zmi/jquery-3.5.1.min.js',
-        '/++resource++zmi/bootstrap-4.6.0/bootstrap.bundle.min.js',
-        '/++resource++zmi/ace.ajax.org/ace.js',
-        '/++resource++zmi/zmi_base.js',
+        prepend_authentication_path(context.REQUEST, path)
+        for path in (
+            '/++resource++zmi/jquery-3.5.1.min.js',
+            '/++resource++zmi/bootstrap-4.6.0/bootstrap.bundle.min.js',
+            '/++resource++zmi/ace.ajax.org/ace.js',
+            '/++resource++zmi/zmi_base.js',
+        )
     )
diff --git a/src/zmi/styles/tests.py b/src/zmi/styles/tests.py
index 36c7b65bec..71ce7d9ce3 100644
--- a/src/zmi/styles/tests.py
+++ b/src/zmi/styles/tests.py
@@ -1,4 +1,7 @@
+"""Testing .subscriber.*"""
+
 import Testing.ZopeTestCase
+from Products.SiteAccess.VirtualHostMonster import VirtualHostMonster
 from Testing.ZopeTestCase.placeless import temporaryPlacelessSetUp
 from Testing.ZopeTestCase.placeless import zcml
 from zope.security.management import endInteraction
@@ -19,9 +22,16 @@ def setupZCML():
 
 
 class SubscriberTests(Testing.ZopeTestCase.FunctionalTestCase):
-    """Testing .subscriber.*"""
+    """Test subscribers URL generation with a user from a user folder
+    not at the root.
+    """
+    request_path = f"/{Testing.ZopeTestCase.folder_name}/manage_main"
+    resources_base_path = f"/{Testing.ZopeTestCase.folder_name}"
 
-    base_path = f'/{Testing.ZopeTestCase.folder_name}'
+    def afterSetUp(self):
+        if "virtual_hosting" not in self.app:
+            vhm = VirtualHostMonster()
+            vhm.addToContainer(self.app)
 
     def call_manage_main(self):
         """Call /folder/manage_main and return the HTML text."""
@@ -30,9 +40,7 @@ def _call_manage_main(self):
             # temporaryPlacelessSetUp insists in creating an interaction
             # which the WSGI publisher does not expect.
             endInteraction()
-            response = self.publish(
-                f'{self.base_path}/manage_main',
-                basic=basic_auth)
+            response = self.publish(self.request_path, basic=basic_auth)
             return str(response)
         return temporaryPlacelessSetUp(
             _call_manage_main, required_zcml=setupZCML)(self)
@@ -41,12 +49,91 @@ def test_subscriber__css_paths__1(self):
         """The paths it returns are rendered in the ZMI."""
         from .subscriber import css_paths
         body = self.call_manage_main()
-        for path in css_paths(None):
-            self.assertIn(f'href="{self.base_path}{path}"', body)
+        for path in css_paths(self.folder):
+            self.assertIn(f'href="{self.resources_base_path}{path}"', body)
 
     def test_subscriber__js_paths__1(self):
         """The paths it returns are rendered in the ZMI."""
         from .subscriber import js_paths
         body = self.call_manage_main()
-        for path in js_paths(None):
-            self.assertIn(f'src="{self.base_path}{path}"', body)
+        for path in js_paths(self.folder):
+            self.assertIn(f'src="{self.resources_base_path}{path}"', body)
+
+
+class SubscriberTestsUserFromRootUserFolderViewingRootFolder(SubscriberTests):
+    """Tests subscribers URL generation with a user from the root acl_users,
+    viewing the root of ZMI.
+    """
+
+    request_path = "/manage_main"
+    resources_base_path = ""
+
+    def _setupFolder(self):
+        self.folder = self.app
+
+    def _setupUserFolder(self):
+        # we use the user folder from self.app
+        pass
+
+
+class SubscriberTestsUserFromRootUserFolderViewingFolder(SubscriberTests):
+    """Tests subscribers URL generation with a user from the root acl_users,
+    viewing a folder not at the root of ZMI. In such case, the URLs are not
+    relative to that folder, the resources are served from the root.
+    """
+
+    request_path = f"/{Testing.ZopeTestCase.folder_name}/manage_main"
+    resources_base_path = ""
+
+    def _setupUser(self):
+        uf = self.app.acl_users
+        uf.userFolderAddUser(
+            Testing.ZopeTestCase.user_name,
+            Testing.ZopeTestCase.user_password,
+            ["Manager"],
+            [],
+        )
+
+    def setRoles(self, roles, name=...):
+        # we set roles in _setupUser
+        pass
+
+    def login(self):
+        pass
+
+
+class SubscriberUrlWithVirtualHostingTests(SubscriberTests):
+    """Tests subscribers URL generation using virtual host."""
+
+    request_path = (
+        "/VirtualHostBase/https/example.org:443/VirtualHostRoot/"
+        f"{Testing.ZopeTestCase.folder_name}/manage_main"
+    )
+    resources_base_path = f"/{Testing.ZopeTestCase.folder_name}"
+
+
+class SubscriberUrlWithVirtualHostingAndUserFolderInVirtualHostTests(
+        SubscriberTests):
+    """Tests subscribers URL generation using virtual host, when
+    the authentication path is part of the virtual host base.
+    """
+
+    request_path = (
+        "/VirtualHostBase/https/example.org:443/"
+        f"{Testing.ZopeTestCase.folder_name}/VirtualHostRoot/manage_main"
+    )
+    resources_base_path = ""
+
+
+class SubscriberUrlWithVirtualHostingAndVHTests(SubscriberTests):
+    """Tests subscribers URL generation using virtual host, when
+    the authentication path is part of the virtual host base and
+    using a "_vh_" path element.
+    """
+
+    request_path = (
+        "/VirtualHostBase/https/example.org:443"
+        f"/{Testing.ZopeTestCase.folder_name}/"
+        "VirtualHostRoot/_vh_zz/manage_main"
+    )
+    resources_base_path = "/zz"