diff --git a/src/main/java/com/knowledgepixels/nanodash/Utils.java b/src/main/java/com/knowledgepixels/nanodash/Utils.java index bd176f29..333ee2f5 100644 --- a/src/main/java/com/knowledgepixels/nanodash/Utils.java +++ b/src/main/java/com/knowledgepixels/nanodash/Utils.java @@ -21,10 +21,12 @@ import org.eclipse.rdf4j.model.IRI; import org.eclipse.rdf4j.model.Literal; import org.eclipse.rdf4j.model.Statement; +import org.eclipse.rdf4j.model.Value; import org.eclipse.rdf4j.model.ValueFactory; import org.eclipse.rdf4j.model.impl.SimpleValueFactory; import org.eclipse.rdf4j.model.util.Literals; import org.eclipse.rdf4j.model.vocabulary.FOAF; +import org.eclipse.rdf4j.model.vocabulary.RDF; import org.eclipse.rdf4j.model.vocabulary.XSD; import org.nanopub.Nanopub; import org.nanopub.NanopubUtils; @@ -673,6 +675,10 @@ public static String sanitizeSvg(String rawSvg) { /** * Checks if a given string is likely to be HTML content. * + *

This is a heuristic, needed wherever the RDF datatype is not available (query + * results arrive as flat strings). Where an actual literal is at hand, prefer + * {@link #isHtmlLiteral(Value)}, which relies on the declared datatype instead. + * * @param value the string to check * @return true if the given string is HTML content, false otherwise */ @@ -680,6 +686,17 @@ public static boolean looksLikeHtml(String value) { return LEADING_TAG.matcher(value).find(); } + /** + * Checks whether a value is a literal explicitly tagged with the {@code rdf:HTML} + * datatype, i.e. whose content is meant to be rendered as HTML. + * + * @param value the value to check + * @return true if the value is an rdf:HTML literal, false otherwise + */ + public static boolean isHtmlLiteral(Value value) { + return value instanceof Literal literal && RDF.HTML.equals(literal.getDatatype()); + } + public static boolean isDate(String value) { return isDateLiteral(value) || isDateTimeLiteral(value); } diff --git a/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.html b/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.html index 79082010..e7f6e1b7 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.html +++ b/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.html @@ -7,7 +7,7 @@ -"" + diff --git a/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.java b/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.java index c7b79b0b..12fc95fc 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/LiteralItem.java @@ -1,7 +1,9 @@ package com.knowledgepixels.nanodash.component; +import com.knowledgepixels.nanodash.Utils; import com.knowledgepixels.nanodash.component.StatementItem.RepetitionGroup; import com.knowledgepixels.nanodash.template.UnificationException; +import org.apache.wicket.behavior.AttributeAppender; import org.apache.wicket.markup.html.basic.Label; import org.apache.wicket.markup.html.panel.Panel; import org.eclipse.rdf4j.model.Literal; @@ -31,7 +33,15 @@ public LiteralItem(String id, String parentId, Literal literal, RepetitionGroup super(id); this.literal = literal; // this.context = rg.getContext(); - add(new Label(LABEL_ID, literal.stringValue())); + if (Utils.isHtmlLiteral(literal)) { + // Content tagged as rdf:HTML is rendered as such, not shown as a quoted string + // (see issue #378). + add(new Label(LABEL_ID, Utils.sanitizeHtml(literal.stringValue())) + .setEscapeModelStrings(false) + .add(new AttributeAppender("class", "internal"))); + } else { + add(new Label(LABEL_ID, "\"" + literal.stringValue() + "\"")); + } } /** diff --git a/src/main/java/com/knowledgepixels/nanodash/component/ReadonlyItem.java b/src/main/java/com/knowledgepixels/nanodash/component/ReadonlyItem.java index 9d9f7629..81184cc5 100644 --- a/src/main/java/com/knowledgepixels/nanodash/component/ReadonlyItem.java +++ b/src/main/java/com/knowledgepixels/nanodash/component/ReadonlyItem.java @@ -33,6 +33,7 @@ import org.eclipse.rdf4j.model.ValueFactory; import org.eclipse.rdf4j.model.impl.SimpleValueFactory; import org.eclipse.rdf4j.model.util.Literals; +import org.eclipse.rdf4j.model.vocabulary.RDF; import org.eclipse.rdf4j.model.vocabulary.XSD; import org.nanopub.Nanopub; import org.nanopub.NanopubUtils; @@ -473,18 +474,22 @@ public void unifyWith(Value v) throws UnificationException { linkComp.add(AttributeAppender.append("class", "long-literal collapsed")); showMoreLabelLiteral.setVisible(true); } + boolean renderAsHtml = renderAsHtml(vL); + // An rdf:HTML literal gets no datatype marker: the rendered content itself shows + // what it is, just as dates are shown without an "(xsd:date)" suffix. + boolean showDatatype = !vL.getDatatype().equals(XSD.STRING) && !renderAsHtml; if (vL.getLanguage().isPresent()) { model.setObject("\"" + vs + "\""); languageModel.setObject("(" + Literals.normalizeLanguageTag(vL.getLanguage().get()) + ")"); languageComp.setVisible(true); - } else if (!vL.getDatatype().equals(XSD.STRING)) { + } else if (showDatatype) { model.setObject("\"" + vs + "\""); datatypeModel.setObject("(" + vL.getDatatype().stringValue().replace(XSD.NAMESPACE, "xsd:") + ")"); datatypeComp.setVisible(true); } else { model.setObject("\"" + vs + "\""); } - if (Utils.looksLikeHtml(vs)) { + if (renderAsHtml) { linkComp.setVisible(false); extraModel.setObject(Utils.sanitizeHtml(vs)); extraComp.setEscapeModelStrings(false); @@ -495,6 +500,27 @@ public void unifyWith(Value v) throws UnificationException { } } + /** + * Decides whether a literal's content is to be rendered as HTML rather than escaped. + * + *

The datatype decides: {@code rdf:HTML} on the literal itself, or declared by the + * template for this placeholder, means HTML. Only when the template declares no + * datatype at all do we fall back to the pattern heuristic, which covers nanopubs + * published before HTML content was tagged, and those made without a template + * (see issue #378). + * + * @param literal the literal to be displayed + * @return true if the content is to be rendered as HTML + */ + private boolean renderAsHtml(Literal literal) { + if (Utils.isHtmlLiteral(literal)) return true; + // A language-tagged literal is an rdf:langString, so a datatype the template + // declares alongside the tag does not apply to it. + IRI declaredDatatype = literal.getLanguage().isPresent() ? null : template.getDatatype(iri); + if (declaredDatatype != null) return RDF.HTML.equals(declaredDatatype); + return Utils.looksLikeHtml(literal.stringValue()); + } + /** * Validator class for validating the input. */ diff --git a/src/test/java/com/knowledgepixels/nanodash/UtilsTest.java b/src/test/java/com/knowledgepixels/nanodash/UtilsTest.java index 7c4733f4..e206474d 100644 --- a/src/test/java/com/knowledgepixels/nanodash/UtilsTest.java +++ b/src/test/java/com/knowledgepixels/nanodash/UtilsTest.java @@ -1,6 +1,9 @@ package com.knowledgepixels.nanodash; -import com.knowledgepixels.nanodash.utils.TestUtils; +import java.util.List; +import java.util.Map; +import java.util.Set; + import org.apache.wicket.markup.html.link.ExternalLink; import org.apache.wicket.request.mapper.parameter.PageParameters; import org.apache.wicket.util.tester.WicketTester; @@ -8,22 +11,29 @@ import org.eclipse.rdf4j.model.Literal; import org.eclipse.rdf4j.model.Statement; import org.eclipse.rdf4j.model.util.Values; +import static org.eclipse.rdf4j.model.util.Values.iri; +import static org.eclipse.rdf4j.model.util.Values.literal; import org.eclipse.rdf4j.model.vocabulary.FOAF; +import org.eclipse.rdf4j.model.vocabulary.RDF; import org.eclipse.rdf4j.model.vocabulary.XSD; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; -import org.nanopub.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import org.nanopub.MalformedNanopubException; +import org.nanopub.Nanopub; +import org.nanopub.NanopubAlreadyFinalizedException; +import org.nanopub.NanopubCreator; +import org.nanopub.NanopubUtils; import org.nanopub.vocabulary.FIP; import org.nanopub.vocabulary.NPX; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import static org.eclipse.rdf4j.model.util.Values.iri; -import static org.eclipse.rdf4j.model.util.Values.literal; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import com.knowledgepixels.nanodash.utils.TestUtils; class UtilsTest { @@ -137,6 +147,22 @@ void sanitizeHtmlPolicyPreservesAllowedProtocols() { assertEquals("Email", sanitizedHtml); } + @Test + void isHtmlLiteralRecognizesRdfHtmlDatatype() { + assertTrue(Utils.isHtmlLiteral(literal("

Hello

", RDF.HTML))); + assertTrue(Utils.isHtmlLiteral(literal("no tags at all", RDF.HTML)), + "the datatype decides, not the content"); + } + + @Test + void isHtmlLiteralRejectsOtherValues() { + assertFalse(Utils.isHtmlLiteral(literal("

Hello

")), "plain string literal"); + assertFalse(Utils.isHtmlLiteral(literal("

Hello

", "en")), "language-tagged literal"); + assertFalse(Utils.isHtmlLiteral(literal("42", XSD.INTEGER)), "other datatype"); + assertFalse(Utils.isHtmlLiteral(iri("https://example.org/thing")), "IRI"); + assertFalse(Utils.isHtmlLiteral(null), "null"); + } + @Test void sanitizeSvgKeepsStaticSvgSubset() { String rawSvg = "" @@ -149,10 +175,10 @@ void sanitizeSvgKeepsStaticSvgSubset() { + "Registry (full label)" + ""; String sanitized = Utils.sanitizeSvg(rawSvg); - for (String kept : new String[] {"Registry (full label)"}) { + for (String kept : new String[]{"Registry (full label)"}) { assertTrue(sanitized.contains(kept), "expected to keep: " + kept + " in: " + sanitized); } } @@ -178,8 +204,8 @@ void sanitizeSvgRemovesScriptingAndStyling() { + "" + ""; String sanitized = Utils.sanitizeSvg(rawSvg); - for (String dropped : new String[] {"Hello world

", RDF.HTML); + LiteralItem item = new LiteralItem("literalItem", null, literal, null); + + tester.startComponentInPage(item); + String html = tester.getLastResponseAsString(); + assertTrue(html.contains("

Hello world

"), html); + assertFalse(html.contains("<p>"), html); + } + + @Test + void htmlLiteralIsSanitized() { + Literal literal = TestUtils.vf.createLiteral("

Hi

", RDF.HTML); + LiteralItem item = new LiteralItem("literalItem", null, literal, null); + + tester.startComponentInPage(item); + String html = tester.getLastResponseAsString(); + assertFalse(html.contains("onclick"), html); + assertFalse(html.contains("", RDF.HTML)); + // (the panel's own markup carries an onclick, so look for the injected payload) + assertFalse(html.contains("alert("), html); + assertFalse(html.contains("