Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 78 additions & 24 deletions src/main/java/com/knowledgepixels/nanodash/page/NanodashPage.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import org.apache.wicket.markup.head.IHeaderResponse;
import org.apache.wicket.markup.head.JavaScriptHeaderItem;
import org.apache.wicket.markup.head.JavaScriptReferenceHeaderItem;
import org.apache.wicket.markup.head.MetaDataHeaderItem;
import org.apache.wicket.markup.head.StringHeaderItem;
import org.apache.wicket.markup.html.WebPage;
import org.apache.wicket.protocol.http.WebApplication;
Expand All @@ -29,12 +28,17 @@
import org.apache.wicket.request.http.WebResponse;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import org.apache.wicket.request.resource.JavaScriptResourceReference;
import org.apache.wicket.util.string.Strings;
import org.owasp.html.Encoding;
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.time.Duration;
import java.util.ResourceBundle;
import java.util.regex.Pattern;

/**
* Abstract base class for Nanodash pages.
Expand Down Expand Up @@ -64,6 +68,12 @@ public abstract class NanodashPage extends WebPage {

private String metaDescription = SITE_META_DESCRIPTION;

private static final PolicyFactory TEXT_ONLY_POLICY = new HtmlPolicyBuilder().toFactory();

private static final Pattern BLOCK_BOUNDARY = Pattern.compile(
"</?(?:p|div|br|li|ul|ol|h[1-6]|tr|td|th|table|blockquote|pre|section|article)\\b[^>]*>",
Pattern.CASE_INSENSITIVE);

/**
* Returns the mount path for this page.
*
Expand Down Expand Up @@ -342,13 +352,40 @@ protected String getMetaDescription() {
* one longer than a search result snippet can show is cut short
*/
protected void setMetaDescription(String description) {
if (description == null || description.isBlank()) return;
String oneLine = description.strip().replaceAll("\\s+", " ");
metaDescription = oneLine.length() <= MAX_META_DESCRIPTION_LENGTH
String text = toMetaDescription(description);
if (text != null) metaDescription = text;
}

/**
* Turns a description into the text a meta description holds: descriptions are often
* HTML, which search results and link previews show verbatim, so its markup is dropped
* and its entities decoded; the rest is put on one line and cut short where it is
* longer than a search result snippet can show.
*
* @param description the description, as plain text or HTML
* @return the meta description, or null if nothing is left of the description
*/
static String toMetaDescription(String description) {
if (description == null) return null;
String oneLine = toPlainText(description).strip().replaceAll("\\s+", " ");
if (oneLine.isEmpty()) return null;
return oneLine.length() <= MAX_META_DESCRIPTION_LENGTH
? oneLine
: oneLine.substring(0, MAX_META_DESCRIPTION_LENGTH).stripTrailing() + "\u2026";
}

/**
* The text of an HTML fragment, with block-level boundaries kept as spaces so that
* paragraphs and list items do not run into each other.
*
* @param html the HTML fragment, or plain text
* @return the text, with markup removed and entities decoded
*/
static String toPlainText(String html) {
String spaced = BLOCK_BOUNDARY.matcher(html).replaceAll(" ");
return Encoding.decodeHtml(TEXT_ONLY_POLICY.sanitize(spaced));
}

/**
* The title link previews show for this page, taken from the page's own title label
* where it has one and falling back to the site name where the title is fixed markup.
Expand Down Expand Up @@ -406,8 +443,7 @@ private void renderRdfLinks(IHeaderResponse response) {
RdfSource source = getRdfSource();
if (source == null) return;
for (RdfNegotiation.Variant variant : RdfNegotiation.VARIANTS) {
response.render(MetaDataHeaderItem.forLinkTag("alternate", source.downloadUrl(variant))
.addTagAttribute("type", variant.mediaType()));
response.render(headTag("link", "rel", "alternate", "href", source.downloadUrl(variant), "type", variant.mediaType()));
}
String jsonLd;
try {
Expand All @@ -434,30 +470,48 @@ private void renderPageMetadata(IHeaderResponse response) {
String title = getMetaTitle();
String description = getMetaDescription();
String url = Utils.absolutePageUrl(getClass(), getPageParameters());
response.render(MetaDataHeaderItem.forMetaTag("description", description));
response.render(MetaDataHeaderItem.forLinkTag("canonical", url));
response.render(propertyMetaTag("og:type", "website"));
response.render(propertyMetaTag("og:site_name", SITE_NAME));
response.render(propertyMetaTag("og:title", title));
response.render(propertyMetaTag("og:description", description));
response.render(propertyMetaTag("og:url", url));
response.render(MetaDataHeaderItem.forMetaTag("twitter:card", "summary"));
response.render(MetaDataHeaderItem.forMetaTag("twitter:title", title));
response.render(MetaDataHeaderItem.forMetaTag("twitter:description", description));
response.render(headTag("meta", "name", "description", "content", description));
response.render(headTag("link", "rel", "canonical", "href", url));
response.render(headTag("meta", "property", "og:type", "content", "website"));
response.render(headTag("meta", "property", "og:site_name", "content", SITE_NAME));
response.render(headTag("meta", "property", "og:title", "content", title));
response.render(headTag("meta", "property", "og:description", "content", description));
response.render(headTag("meta", "property", "og:url", "content", url));
response.render(headTag("meta", "name", "twitter:card", "content", "summary"));
response.render(headTag("meta", "name", "twitter:title", "content", title));
response.render(headTag("meta", "name", "twitter:description", "content", description));
}

/**
* A meta tag keyed by {@code property} instead of {@code name}, as Open Graph
* requires.
* A {@code meta} or {@code link} tag for the head, with every attribute value escaped
* for HTML. Wicket's {@code MetaDataHeaderItem} only backslash-escapes double quotes,
* which HTML does not honour, so a value taken from a nanopublication (a space's
* description, say) could end the attribute and put markup of its own into the page.
*
* @param property the property name
* @param content the property value
* @param tagName the tag, {@code meta} or {@code link}
* @param attributes attribute names and values, alternating
* @return the header item
* @throws IllegalArgumentException if a name is given without a value
*/
static StringHeaderItem headTag(String tagName, String... attributes) {
if (attributes.length % 2 != 0) {
throw new IllegalArgumentException("Attribute names and values must come in pairs");
}
StringBuilder tag = new StringBuilder("<").append(tagName);
for (int i = 0; i < attributes.length; i += 2) {
tag.append(' ').append(attributes[i]).append("=\"").append(escapeAttribute(attributes[i + 1])).append('"');
}
return StringHeaderItem.forString(tag.append(" />\n").toString());
}

/**
* Escapes a value for a double- or single-quoted HTML attribute.
*
* @param value the value, or null for an empty one
* @return the escaped value
*/
private static MetaDataHeaderItem propertyMetaTag(String property, String content) {
return new MetaDataHeaderItem(MetaDataHeaderItem.META_TAG)
.addTagAttribute("property", property)
.addTagAttribute("content", content);
static String escapeAttribute(String value) {
return value == null ? "" : Strings.escapeMarkup(value).toString();
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package com.knowledgepixels.nanodash.page;

import com.knowledgepixels.nanodash.WicketApplication;
import org.apache.wicket.request.mapper.parameter.PageParameters;
import org.apache.wicket.util.tester.WicketTester;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
Expand Down Expand Up @@ -76,4 +80,94 @@ void takesTheTitleFromThePage() {
assertTrue(document.contains("property=\"og:title\" content=\"Something went wrong"), document);
}

/**
* An error page describing itself with the given text, standing in for a page whose
* description comes from a nanopublication.
*/
public static class DescribedPage extends ErrorPage {

static String description;

/**
* Creates the page with {@link #description} as its meta description.
*
* @param parameters the page parameters
*/
public DescribedPage(PageParameters parameters) {
super(parameters);
setMetaDescription(description);
}

}

/**
* A description that ends the attribute it is written into cannot put markup into the
* head; it stays text in every tag it appears in.
*/
@Test
void aDescriptionCannotBreakOutOfItsAttribute() {
DescribedPage.description = "Q\"><b id=\"injected\">x</b>' & more";
tester.startPage(DescribedPage.class);
String document = tester.getLastResponse().getDocument();
assertFalse(document.contains("<b id="), document);
assertTrue(document.contains("<meta name=\"description\" content=\"Q&quot;&gt;x&#039; &amp; more\" />"), document);
assertTrue(document.contains("property=\"og:description\" content=\"Q&quot;&gt;x&#039; &amp; more\""), document);
assertTrue(document.contains("<meta name=\"twitter:description\" content=\"Q&quot;&gt;x&#039; &amp; more\" />"), document);
}

/**
* An HTML description, as spaces and presentations often have, shows as its text in
* search results and link previews, not as markup.
*/
@Test
void anHtmlDescriptionIsShownAsText() {
DescribedPage.description = "<span>This year\u2019s theme, <strong>\"Interoperable Europe\"</strong>, comes at a key moment.</span>";
tester.startPage(DescribedPage.class);
String document = tester.getLastResponse().getDocument();
assertTrue(document.contains("<meta name=\"description\" content=\"This year\u2019s theme, &quot;Interoperable Europe&quot;, comes at a key moment.\" />"), document);
assertFalse(document.contains("content=\"<span>"), document);
}

@Test
void headTagEscapesEveryAttributeValue() {
assertEquals("<link rel=\"a&quot;b\" href=\"https://x.org/?a=1&amp;b=&lt;2&gt;\" type=\"t&#039;\" />\n",
NanodashPage.headTag("link", "rel", "a\"b", "href", "https://x.org/?a=1&b=<2>", "type", "t'").getString().toString());
assertEquals("<meta name=\"description\" content=\"\" />\n",
NanodashPage.headTag("meta", "name", "description", "content", null).getString().toString());
}

@Test
void headTagRefusesAnAttributeWithoutValue() {
assertThrows(IllegalArgumentException.class, () -> NanodashPage.headTag("meta", "name"));
}

@Test
void plainTextKeepsBlocksApartAndDecodesEntities() {
assertEquals("One two & three",
NanodashPage.toMetaDescription("<p>One</p><p>two &amp;</p><ul><li>three</li></ul>"));
assertEquals("a < b", NanodashPage.toMetaDescription("a &lt; b"));
assertEquals("Line one Line two", NanodashPage.toMetaDescription("Line one<br/>Line two"));
}

@Test
void plainTextDropsScripts() {
assertEquals("Before after", NanodashPage.toMetaDescription("Before <script>alert(1)</script>after"));
}

@Test
void metaDescriptionIsCutAfterTheMarkupIsRemoved() {
String text = "word ".repeat(80).strip();
String description = NanodashPage.toMetaDescription("<span>" + text + "</span>");
assertTrue(description.endsWith("\u2026"), description);
assertTrue(description.length() <= 301, description);
assertFalse(description.contains("<"), description);
}

@Test
void noMetaDescriptionWhenNothingIsLeft() {
assertNull(NanodashPage.toMetaDescription(null));
assertNull(NanodashPage.toMetaDescription(" "));
assertNull(NanodashPage.toMetaDescription("<span> </span><br/>"));
}

}