Skip to content

Commit 3cb9f56

Browse files
committed
XWIKI-22154: "aria-labelledby" for navigation panel entries and corresponding ids contain illegal whitespaces
* Escaped the node ids on the way into the tree and unescaped them only on the way out * Percent-encoded the node ids, keeping the colon and the at sign as is * Escaped the node ids passed to openTo() and the ones specified inline through data-json * Unescaped the selected node ids written into the tree field value * Escaped the node ids built by the export tree, the navigation panel configuration, the in-place editor and the annotation configuration * Unescaped the selected node ids in the template provider picker and the WYSIWYG image selector * Escaped and unescaped the node ids in the tree page objects * Added a functional test for the escaping of the node ids * Added a functional test for moving a page to a parent whose name contains white space
1 parent 9bb55c8 commit 3cb9f56

12 files changed

Lines changed: 227 additions & 61 deletions

File tree

  • xwiki-platform-core
    • xwiki-platform-administration/xwiki-platform-administration-ui/src/main/resources/XWiki
    • xwiki-platform-annotation/xwiki-platform-annotation-ui/src/main/resources/AnnotationCode
    • xwiki-platform-ckeditor/xwiki-platform-ckeditor-test/xwiki-platform-ckeditor-test-pageobjects/src/main/java/org/xwiki/ckeditor/test/po
    • xwiki-platform-edit/xwiki-platform-edit-ui/src/main/resources/XWiki
    • xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/src/test/it/org/xwiki/flamingo/test/docker
    • xwiki-platform-index/xwiki-platform-index-test/xwiki-platform-index-test-docker/src/test/it/org/xwiki/index/test/ui/docker
    • xwiki-platform-panels/xwiki-platform-panels-ui/src/main/resources/PanelsCode
    • xwiki-platform-tree
      • xwiki-platform-tree-test-pageobjects/src/main/java/org/xwiki/tree/test/po
      • xwiki-platform-tree-webjar/src/main/webjar
    • xwiki-platform-web/xwiki-platform-web-war/src/main/webapp/resources/uicomponents/exporter
    • xwiki-platform-wysiwyg

xwiki-platform-core/xwiki-platform-administration/xwiki-platform-administration-ui/src/main/resources/XWiki/TemplateProviderMacros.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,8 @@
214214
$('.location-picker.modal').prop('modalTrigger', $(this)).modal();
215215
});
216216

217-
var getLocalSpaceReference = function(nodeId) {
217+
var getLocalSpaceReference = function(escapedNodeId) {
218+
var nodeId = $.fn.xtree.unescapeNodeId(escapedNodeId);
218219
var separatorIndex = nodeId.indexOf(':');
219220
var nodeType = nodeId.substr(0, separatorIndex);
220221
var nodeStringReference = nodeId.substr(separatorIndex + 1);

xwiki-platform-core/xwiki-platform-annotation/xwiki-platform-annotation-ui/src/main/resources/AnnotationCode/AnnotationConfigSheet.xml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,21 +289,22 @@ $annotationsConfigDoc.display('annotationClass', 'edit')
289289
// Once the node is loaded, check it, if possible.
290290
// If the node did not load (lack of permissions, hidden document, etc.), nothing happens.
291291
tree.check_node(idToCheck);
292-
}.bind(this, savedId));
292+
}.bind(this, $.fn.xtree.escapeNodeId(savedId)));
293293
}
294294
});
295295

296296
// Listen to tree nodes being checked/unchecked and save the changes in the DOM to be saved on submit.
297297
treeElement.on('changed.jstree', function(event, data) {
298-
var node = data.node;
298+
// The savedIds list holds the node ids as they are serialized in the form field, i.e. unescaped.
299+
var nodeId = $.fn.xtree.unescapeNodeId(data.node.id);
299300
var initialNrOfNodes = savedIds.length;
300301

301302
// Update the savedIds list.
302-
var savedIdIndex = savedIds.indexOf(node.id);
303+
var savedIdIndex = savedIds.indexOf(nodeId);
303304
if (data.action == 'select_node') {
304305
// If it's not in the list, add it.
305306
if (savedIdIndex == -1) {
306-
savedIds.push(node.id);
307+
savedIds.push(nodeId);
307308
}
308309
} else if (data.action == 'deselect_node') {
309310
// If it's in the list, remove it.

xwiki-platform-core/xwiki-platform-ckeditor/xwiki-platform-ckeditor-test/xwiki-platform-ckeditor-test-pageobjects/src/main/java/org/xwiki/ckeditor/test/po/LinkTreeElement.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ public LinkTreeElement waitForNewPageCreation(EntityReference reference)
8787
*/
8888
public void createNode(DocumentReference origin, String name)
8989
{
90-
String createDocumentNodeId = getCreateDocumentNodeId(origin);
90+
String createDocumentNodeId = escapeNodeId(getCreateDocumentNodeId(origin));
9191
WebElement originNode = getDriver().findElement(By.id(createDocumentNodeId));
9292
originNode.findElement(By.tagName("a")).click();
9393
// We cannot reuse the element
@@ -97,7 +97,7 @@ public void createNode(DocumentReference origin, String name)
9797
SpaceReference targetSpace = new SpaceReference(name, origin.getLastSpaceReference());
9898
DocumentReference target = new DocumentReference("WebHome", targetSpace);
9999
getDriver().waitUntilCondition(
100-
ExpectedConditions.presenceOfElementLocated(By.id(getNodeId(target)))
100+
ExpectedConditions.presenceOfElementLocated(By.id(escapeNodeId(getNodeId(target))))
101101
);
102102
openTo(getNodeId(target));
103103
}

xwiki-platform-core/xwiki-platform-edit/xwiki-platform-edit-ui/src/main/resources/XWiki/InplaceEditing.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1263,7 +1263,7 @@ require(['jquery', 'xwiki-meta'], function($, meta) {
12631263
var updateDocTrees = function(xwikiDocument) {
12641264
var plainTitle = xwikiDocument.getPlainTitle();
12651265
$('.jstree-xwiki').each(function() {
1266-
$(this).jstree?.(true)?.set_text?.('document:' + xwikiDocument.id, plainTitle);
1266+
$(this).jstree?.(true)?.set_text?.($.fn.xtree.escapeNodeId('document:' + xwikiDocument.id), plainTitle);
12671267
});
12681268
};
12691269

xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/src/test/it/org/xwiki/flamingo/test/docker/RenamePageIT.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.xwiki.flamingo.skin.test.po.AttachmentsPane;
3636
import org.xwiki.flamingo.skin.test.po.AttachmentsViewPage;
3737
import org.xwiki.flamingo.skin.test.po.JobQuestionPane;
38+
import org.xwiki.index.tree.test.po.DocumentPickerModal;
3839
import org.xwiki.model.reference.AttachmentReference;
3940
import org.xwiki.model.reference.DocumentReference;
4041
import org.xwiki.model.reference.EntityReference;
@@ -869,4 +870,42 @@ void convertTerminalPageToNestedPage(TestUtils setup) throws Exception
869870
assertTrue(breadcrumb.endsWith("/4/child"),
870871
"Expected breadcrumb to end with /4/child but was [" + breadcrumb + "]");
871872
}
873+
874+
/**
875+
* Move a page to a parent whose name contains white space, using the tree picker. The tree escapes the identifier
876+
* of its nodes so that they can be used as HTML identifiers, so this checks that it unescapes them back before
877+
* sending them to the server. See XWIKI-22151 and XWIKI-22154.
878+
*/
879+
@Order(13)
880+
@Test
881+
void renamePageToParentWithWhiteSpaceUsingTreePicker(TestUtils setup) throws Exception
882+
{
883+
String targetSpaceName = "Rename Space";
884+
DocumentReference targetParent = new DocumentReference("xwiki", targetSpaceName, "WebHome");
885+
DocumentReference source =
886+
new DocumentReference("xwiki", Arrays.asList("RenamePageIT", "MoveToSpaceWithWhiteSpace"), "WebHome");
887+
DocumentReference target =
888+
new DocumentReference("xwiki", Arrays.asList(targetSpaceName, "MoveToSpaceWithWhiteSpace"), "WebHome");
889+
890+
setup.rest().delete(targetParent);
891+
setup.rest().delete(source);
892+
setup.rest().delete(target);
893+
894+
setup.createPage(targetParent, "", targetSpaceName);
895+
setup.createPage(source, "", "MoveToSpaceWithWhiteSpace");
896+
897+
new SolrTestUtils(setup).waitEmptyQueue();
898+
899+
RenamePage renamePage = setup.gotoPage(source).rename();
900+
renamePage.getDocumentPicker().browseDocuments();
901+
new DocumentPickerModal().selectDocument(targetSpaceName, "WebHome");
902+
renamePage.clickRenameButton().waitUntilFinished();
903+
904+
// The page landed in the space that was selected in the tree, not in a space named after its escaped
905+
// identifier (e.g. "Rename%20Space").
906+
assertTrue(setup.pageExists(Arrays.asList(targetSpaceName, "MoveToSpaceWithWhiteSpace"), "WebHome"),
907+
"The page was not moved to the [" + targetSpaceName + "] space.");
908+
assertFalse(setup.pageExists(Arrays.asList("RenamePageIT", "MoveToSpaceWithWhiteSpace"), "WebHome"),
909+
"The page is still in its original location.");
910+
}
872911
}

xwiki-platform-core/xwiki-platform-index/xwiki-platform-index-test/xwiki-platform-index-test-docker/src/test/it/org/xwiki/index/test/ui/docker/DocumentTreeMacroIT.java

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.HashMap;
2323
import java.util.List;
2424
import java.util.Map;
25+
import java.util.regex.Pattern;
2526

2627
import org.junit.jupiter.api.Order;
2728
import org.junit.jupiter.api.Test;
@@ -51,6 +52,13 @@
5152
@UITest
5253
class DocumentTreeMacroIT
5354
{
55+
/**
56+
* The HTML attributes that hold a tree node identifier.
57+
*/
58+
private static final List<String> ID_ATTRIBUTE_NAMES = List.of("id", "aria-activedescendant", "aria-labelledby");
59+
60+
private static final Pattern WHITE_SPACE = Pattern.compile("\\s");
61+
5462
@Test
5563
@Order(1)
5664
void sortDocumentsBy(TestUtils setup, TestReference testReference) throws Exception
@@ -477,6 +485,55 @@ void documentTreeRootOpenTo(TestUtils setup, TestReference testReference)
477485
assertFalse(tree.hasNode(getNodeId(subAlice)));
478486
}
479487

488+
/**
489+
* Verify that the node identifiers rendered by the tree don't contain characters that are forbidden in an HTML
490+
* identifier, and that the tree still resolves them back to the right entity reference when it talks to the server.
491+
*/
492+
@Test
493+
@Order(7)
494+
void escapeNodeIds(TestUtils setup, TestReference testReference)
495+
{
496+
SpaceReference testSpaceReference = testReference.getLastSpaceReference();
497+
DocumentReference parent = new DocumentReference("WebHome", new SpaceReference("A B", testSpaceReference));
498+
DocumentReference child =
499+
new DocumentReference("WebHome", new SpaceReference("C D", parent.getLastSpaceReference()));
500+
501+
setup.loginAsSuperAdmin();
502+
// Clean up.
503+
setup.deletePage(testReference, true);
504+
createPage(setup, parent, "", "");
505+
createPage(setup, child, "", "");
506+
507+
TreeElement tree = getDocumentTree(setup, testReference, true, Map.of("openTo", getNodeId(child)));
508+
509+
// The tree opened to the specified node, which means it was able to send the escaped node identifiers back to
510+
// the server, unescaped, both to compute the path and to load the child nodes.
511+
assertTrue(tree.hasNode(getNodeId(child)));
512+
assertNodeLabels(tree.getNode(getNodeId(parent)).getChildren(), "C D");
513+
assertEquals(List.of(getNodeId(child)), tree.getSelectedNodeIDs());
514+
515+
// None of the identifiers rendered by the tree contains white space, which is forbidden in an HTML identifier.
516+
List<String> renderedIds = getRenderedIds(setup, testSpaceReference.getName());
517+
assertTrue(renderedIds.contains(TreeElement.escapeNodeId(getNodeId(child))),
518+
"The node with white space in its reference was not rendered: " + renderedIds);
519+
renderedIds.forEach(renderedId -> assertFalse(WHITE_SPACE.matcher(renderedId).find(),
520+
"The rendered identifier [" + renderedId + "] contains white space."));
521+
}
522+
523+
/**
524+
* @return the values of the attributes that hold a node identifier, collected from the tree with the given HTML
525+
* identifier and from all its descendants
526+
*/
527+
@SuppressWarnings("unchecked")
528+
private List<String> getRenderedIds(TestUtils setup, String treeId)
529+
{
530+
return (List<String>) setup.getDriver().executeScript("const attributeNames = arguments[0];"
531+
+ "const tree = document.getElementById(arguments[1]);"
532+
+ "return [tree, ...tree.querySelectorAll('*')]"
533+
+ " .flatMap(element => attributeNames.map(name => element.getAttribute(name)))"
534+
+ " .filter(value => value !== null);", ID_ATTRIBUTE_NAMES, treeId);
535+
}
536+
480537
private ViewPage createPage(TestUtils setup, DocumentReference documentReference, String title, String content)
481538
{
482539
// We don't care what parent page is used, we just want to avoid creating orphan pages in order to not interfere

xwiki-platform-core/xwiki-platform-panels/xwiki-platform-panels-ui/src/main/resources/PanelsCode/NavigationConfigurationSheet.xml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ require([
316316
'xwiki-l10n!xwiki-panels-navigation-config-messages',
317317
'xwiki-meta',
318318
'jquery-ui',
319+
'xwiki-tree',
319320
'xwiki-events-bridge'
320321
], function($, l10n, xwikiMeta) {
321322
var getLocalDocumentReference = function(absoluteDocumentReference) {
@@ -366,7 +367,7 @@ require([
366367
var getNodeData = function(exclusion) {
367368
var documentReference = exclusion.data('reference');
368369
return {
369-
id: 'document:' + documentReference,
370+
id: $.fn.xtree.escapeNodeId('document:' + documentReference),
370371
text: exclusion.text(),
371372
children: exclusion.data('children') !== false,
372373
data: {
@@ -410,7 +411,7 @@ require([
410411
};
411412

412413
var showOrCreateNode = function(tree, page) {
413-
var node = tree.get_node('document:' + page.data('reference'));
414+
var node = tree.get_node($.fn.xtree.escapeNodeId('document:' + page.data('reference')));
414415
if (node) {
415416
tree.show_node(node.id);
416417
} else {
@@ -477,7 +478,7 @@ require([
477478

478479
var excludePagesFromDynamicFilter = function(pages, tree) {
479480
tree &amp;&amp; pages.find('.page').not('.included').each(function() {
480-
tree.hide_node('document:' + $(this).data('reference'));
481+
tree.hide_node($.fn.xtree.escapeNodeId('document:' + $(this).data('reference')));
481482
});
482483
};
483484

xwiki-platform-core/xwiki-platform-tree/xwiki-platform-tree-test-pageobjects/src/main/java/org/xwiki/tree/test/po/TreeElement.java

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
*/
2020
package org.xwiki.tree.test.po;
2121

22+
import java.net.URLDecoder;
23+
import java.nio.charset.StandardCharsets;
2224
import java.util.ArrayList;
2325
import java.util.Arrays;
2426
import java.util.List;
@@ -37,6 +39,12 @@
3739
*/
3840
public class TreeElement extends BaseElement
3941
{
42+
/**
43+
* The characters that {@code encodeURIComponent()} leaves unchanged.
44+
*/
45+
private static final String UNRESERVED_CHARACTERS =
46+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()";
47+
4048
/**
4149
* The element that represents the tree.
4250
*/
@@ -58,7 +66,7 @@ public TreeElement(WebElement element)
5866
*/
5967
public TreeNodeElement getNode(String nodeId)
6068
{
61-
return new TreeNodeElement(this.element, By.id(nodeId));
69+
return new TreeNodeElement(this.element, By.id(escapeNodeId(nodeId)));
6270
}
6371

6472
/**
@@ -67,12 +75,10 @@ public TreeNodeElement getNode(String nodeId)
6775
*/
6876
public boolean hasNode(String nodeId)
6977
{
70-
// We cannot use By.id(nodeId) because findElements returns 0 elements if the id contains special characters
71-
// such as backslash (which is used to escape special characters in an entity reference which the node id can
72-
// be). Such an element id is technically invalid but the browsers are handling it fine.
73-
// See https://code.google.com/p/selenium/issues/detail?id=8173
78+
// We cannot use By.id() because findElements returns 0 elements if the id contains special characters that
79+
// Selenium doesn't escape properly. See https://code.google.com/p/selenium/issues/detail?id=8173
7480
return !getDriver().findElementsWithoutWaiting(this.element,
75-
By.xpath(".//*[@id = '" + nodeId + "']")).isEmpty();
81+
By.xpath(".//*[@id = '" + escapeNodeId(nodeId) + "']")).isEmpty();
7682
}
7783

7884
/**
@@ -133,7 +139,7 @@ public TreeElement waitForIt()
133139
public TreeElement waitForNodeSelected(String nodeId)
134140
{
135141
String selectedNodeXPath =
136-
String.format(".//*[@id = '%s_anchor' and contains(@class, 'jstree-clicked')]", nodeId);
142+
String.format(".//*[@id = '%s_anchor' and contains(@class, 'jstree-clicked')]", escapeNodeId(nodeId));
137143
getDriver().waitUntilElementIsVisible(this.element, By.xpath(selectedNodeXPath));
138144
return this;
139145
}
@@ -149,8 +155,9 @@ protected WebElement getElement()
149155
@SuppressWarnings("unchecked")
150156
public List<String> getSelectedNodeIDs()
151157
{
152-
return (List<String>) getDriver()
158+
List<String> selectedNodeIds = (List<String>) getDriver()
153159
.executeScript("return jQuery.jstree.reference(jQuery(arguments[0])).get_selected()", this.element);
160+
return selectedNodeIds.stream().map(TreeElement::unescapeNodeId).toList();
154161
}
155162

156163
/**
@@ -163,7 +170,48 @@ public List<String> getNodeIDs()
163170
+ "{flat:true, no_data:true, no_state:true})" + ".map(function(element) {return element.id});",
164171
this.element);
165172

166-
return Arrays.asList(selectedNodeIDs);
173+
return Arrays.stream(selectedNodeIDs).map(TreeElement::unescapeNodeId).toList();
174+
}
175+
176+
/**
177+
* Percent-encodes the given node identifier the same way the tree widget does, so that it matches the {@code id}
178+
* attribute of the corresponding HTML element. Page objects that locate a tree node by its identifier need this
179+
* because entity references can contain characters that are forbidden in an HTML identifier, such as white space.
180+
*
181+
* @param nodeId the node identifier, holding the entity reference as is
182+
* @return the escaped node identifier
183+
* @since 18.8.0RC1
184+
*/
185+
public static String escapeNodeId(String nodeId)
186+
{
187+
if (nodeId == null) {
188+
return null;
189+
}
190+
StringBuilder result = new StringBuilder();
191+
for (byte nodeIdByte : nodeId.getBytes(StandardCharsets.UTF_8)) {
192+
char character = (char) (nodeIdByte & 0xFF);
193+
if (UNRESERVED_CHARACTERS.indexOf(character) >= 0) {
194+
result.append(character);
195+
} else {
196+
result.append(String.format("%%%02X", nodeIdByte & 0xFF));
197+
}
198+
}
199+
// The colon and the at sign separate the components of a node identifier and are both valid in an HTML
200+
// identifier, so the tree widget leaves them as is, in order to keep the escaped node identifiers readable.
201+
return result.toString().replace("%3A", ":").replace("%40", "@");
202+
}
203+
204+
/**
205+
* Reverts {@link #escapeNodeId(String)}, turning a node identifier read from the tree back into the entity
206+
* reference it holds.
207+
*
208+
* @param nodeId the escaped node identifier
209+
* @return the node identifier holding the entity reference as is
210+
* @since 18.8.0RC1
211+
*/
212+
public static String unescapeNodeId(String nodeId)
213+
{
214+
return nodeId == null ? null : URLDecoder.decode(nodeId, StandardCharsets.UTF_8);
167215
}
168216

169217
/**

0 commit comments

Comments
 (0)