VIVO-1405 Add pseudo-version to requests for scripts and stylesheets.

The result is that the user does not need to refresh the browser cache when a new version of VIVO is deployed on the server.
This commit is contained in:
Jim Blake 2017-12-01 11:41:04 -05:00
parent 0e15b9a69a
commit b00633326e
2 changed files with 497 additions and 64 deletions

View file

@ -2,82 +2,267 @@
package edu.cornell.mannlib.vitro.webapp.web.templatemodels;
import java.io.File;
import java.lang.reflect.Method;
import java.util.LinkedHashSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.ServletContext;
import freemarker.ext.beans.MethodAppearanceFineTuner;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import edu.cornell.mannlib.vitro.webapp.application.ApplicationUtils;
import edu.cornell.mannlib.vitro.webapp.controller.freemarker.UrlBuilder;
import freemarker.ext.beans.BeansWrapper;
import freemarker.template.TemplateModel;
import freemarker.template.TemplateModelException;
/**
* Provides a mechanism for Freemarker templates (main or included, parent or
* child) to add to the lists of scripts and style sheets for the current page.
*
* Each page uses 3 instances of Tags, exposed as ${scripts}, ${headScripts} and
* ${stylesheets}. A template may add a complete <script/$gt; element (for
* scripts or headScripts) or a <link> tag (for stylesheets), and these
* elements will appear at the proper location in the rendered HTML for the
* page.
*
* VIVO-1405: This process is augmented by the TagVersionInfo inner class, which
* attempts to add a "version=" query string to the URL in the supplied element.
* The version number is derived from the last-modified date of the specified
* script or stylesheet on the server. The effect is that a user's browser cache
* is effectively invalidated each time a new version of the script or
* stylesheet is deployed.
*/
public class Tags extends BaseTemplateModel {
private static final Log log = LogFactory.getLog(Tags.class);
protected final LinkedHashSet<String> tags;
private static final Log log = LogFactory.getLog(Tags.class);
public Tags() {
this.tags = new LinkedHashSet<String>();
}
public Tags(LinkedHashSet<String> tags) {
this.tags = tags;
}
public TemplateModel wrap() {
try {
return new TagsWrapper().wrap(this);
} catch (TemplateModelException e) {
log.error("Error creating Tags template model");
return null;
}
}
/** Script and stylesheet lists are wrapped with a specialized BeansWrapper
* that exposes certain write methods, instead of the configuration's object wrapper,
* which doesn't. The templates can then add stylesheets and scripts to the lists
* by calling their add() methods.
*/
static public class TagsWrapper extends BeansWrapper {
public TagsWrapper() {
// Start by exposing all safe methods.
setExposureLevel(EXPOSE_SAFE);
setMethodAppearanceFineTuner(new MethodAppearanceFineTuner() {
@Override
public void process(MethodAppearanceDecisionInput methodAppearanceDecisionInput, MethodAppearanceDecision methodAppearanceDecision) {
try {
String methodName = methodAppearanceDecisionInput.getMethod().getName();
if ( ! ( methodName.equals("add") || methodName.equals("list")) ) {
methodAppearanceDecision.setExposeMethodAs(null);
}
} catch (Exception e) {
log.error(e, e);
}
}
});
}
}
/* Template methods */
protected final LinkedHashSet<String> tags;
public void add(String... tags) {
for (String tag : tags) {
add(tag);
}
}
public void add(String tag) {
tags.add(tag);
}
public String list() {
return StringUtils.join(tags, "\n");
}
public Tags() {
this.tags = new LinkedHashSet<String>();
}
public Tags(LinkedHashSet<String> tags) {
this.tags = tags;
}
public TemplateModel wrap() {
try {
return new TagsWrapper().wrap(this);
} catch (TemplateModelException e) {
log.error("Error creating Tags template model");
return null;
}
}
/**
* Script and stylesheet lists are wrapped with a specialized BeansWrapper
* that exposes certain write methods, instead of the configuration's object
* wrapper, which doesn't. The templates can then add stylesheets and
* scripts to the lists by calling their add() methods.
*
* @param Tags
* tags
* @return TemplateModel
*/
static public class TagsWrapper extends BeansWrapper {
public TagsWrapper() {
// Start by exposing all safe methods.
setExposureLevel(EXPOSE_SAFE);
}
@SuppressWarnings("rawtypes")
@Override
protected void finetuneMethodAppearance(Class cls, Method method,
MethodAppearanceDecision decision) {
try {
String methodName = method.getName();
if (!(methodName.equals("add") || methodName.equals("list"))) {
decision.setExposeMethodAs(null);
}
} catch (Exception e) {
log.error(e, e);
}
}
}
/* Template methods */
@SuppressWarnings("hiding")
public void add(String... tags) {
for (String tag : tags) {
add(tag);
}
}
public void add(String tag) {
TagVersionInfo info = new TagVersionInfo(tag);
if (info.hasVersion()) {
tags.add(TagVersionInfo.addVersionNumber(tag, info));
} else {
tags.add(tag);
}
}
public String list() {
return StringUtils.join(tags, "\n");
}
/**
* Find the value of "href" or "src".
*
* If there is such a value, and it doesn't have a query string already, and
* it represents a local URL, and we can locate the file that is served by
* the URL and get the last modified date, then we have found a "version
* number" that we can add to the attribute value.
*
* Reference for parsing attributes:
* https://www.w3.org/TR/html/syntax.html#elements-attributes
*/
protected static class TagVersionInfo {
private static final Pattern PATTERN_DOUBLE_QUOTES = Pattern
.compile("(href|src)\\s*=\\s*\"([^\"]+)\"[\\s|>]");
private static final int GROUP_INDEX_DOUBLE_QUOTES = 2;
private static final Pattern PATTERN_SINGLE_QUOTES = Pattern
.compile("(href|src)\\s*=\\s*'([^']+)'[\\s|>]");
private static final int GROUP_INDEX_SINGLE_QUOTES = 2;
private static final Pattern PATTERN_NO_QUOTES = Pattern
.compile("(href|src)\\s*=\\s*([^\"'<=>\\s]+)[\\s|>]");
private static final int GROUP_INDEX_NO_QUOTES = 2;
public static String addVersionNumber(String rawTag,
TagVersionInfo info) {
String versionString = (info.match.style == MatchResult.Style.NO_QUOTES)
? "?version&eq;"
: "?version=";
return rawTag.substring(0, info.match.start) + info.match.group
+ versionString + smushTimeStamp(info)
+ rawTag.substring(info.match.end);
}
private static String smushTimeStamp(TagVersionInfo info) {
int smushed = (((char) (info.timestamp >> 48))
^ ((char) (info.timestamp >> 32))
^ ((char) (info.timestamp >> 16))
^ ((char) info.timestamp));
return String.format("%04x", smushed);
}
private MatchResult match;
private long timestamp = 0L;
public TagVersionInfo(String rawTag) {
try {
match = findUrlValue(rawTag);
if (match != null && !hasQueryString(match.group)) {
String stripped = stripContextPath(match.group);
if (stripped != null) {
String realPath = locateRealPath(stripped);
if (realPath != null) {
timestamp = getLastModified(realPath);
}
}
}
} catch (Exception e) {
log.debug("Failed to add version info to tag: " + rawTag, e);
timestamp = 0L;
}
}
public boolean hasVersion() {
return timestamp != 0L;
}
private static MatchResult findUrlValue(String rawTag) {
Matcher mDouble = PATTERN_DOUBLE_QUOTES.matcher(rawTag);
if (mDouble.find()) {
return new MatchResult(mDouble, GROUP_INDEX_DOUBLE_QUOTES,
MatchResult.Style.DOUBLE_QUOTES);
}
Matcher mSingle = PATTERN_SINGLE_QUOTES.matcher(rawTag);
if (mSingle.find()) {
return new MatchResult(mSingle, GROUP_INDEX_SINGLE_QUOTES,
MatchResult.Style.SINGLE_QUOTES);
}
Matcher mNo = PATTERN_NO_QUOTES.matcher(rawTag);
if (mNo.find()) {
return new MatchResult(mNo, GROUP_INDEX_NO_QUOTES,
MatchResult.Style.NO_QUOTES);
}
log.debug(rawTag + " no match");
return null;
}
private static boolean hasQueryString(String group) {
if (group.indexOf('?') > -1) {
log.debug(group + " has query string already");
return true;
} else {
return false;
}
}
private static String stripContextPath(String group) {
String contextPath = UrlBuilder.getBaseUrl();
if (contextPath.isEmpty() || group.startsWith(contextPath)) {
return group.substring(contextPath.length());
} else {
log.debug(group + " doesn't match context path");
return null;
}
}
private static String locateRealPath(String stripped) {
ServletContext ctx = ApplicationUtils.instance()
.getServletContext();
String realPath = ctx.getRealPath(stripped);
if (realPath == null) {
log.debug(stripped + " has no real path");
}
return realPath;
}
private static long getLastModified(String realPath) {
return new File(realPath).lastModified();
}
protected static class MatchResult {
public enum Style {
SINGLE_QUOTES, DOUBLE_QUOTES, NO_QUOTES
}
public final String group;
public final int start;
public final int end;
public final Style style;
public MatchResult(Matcher matcher, int group, Style style) {
this.group = matcher.group(group);
this.start = matcher.start(group);
this.end = matcher.end(group);
this.style = style;
log.debug(this);
}
@Override
public String toString() {
return "MatchResult[start=" + start + ", end=" + end
+ ", group=" + group + ", style=" + style + "]";
}
}
}
}

View file

@ -0,0 +1,248 @@
package edu.cornell.mannlib.vitro.webapp.web.templatemodels;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.fail;
import java.io.File;
import java.io.IOException;
import java.io.StringWriter;
import java.lang.reflect.Field;
import org.apache.log4j.Level;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;
import edu.cornell.mannlib.vitro.testing.AbstractTestClass;
import edu.cornell.mannlib.vitro.webapp.controller.freemarker.UrlBuilder;
import edu.cornell.mannlib.vitro.webapp.web.templatemodels.Tags.TagVersionInfo;
import edu.cornell.mannlib.vitro.webapp.web.templatemodels.Tags.TagVersionInfo.MatchResult;
import stubs.edu.cornell.mannlib.vitro.webapp.modules.ApplicationStub;
import stubs.javax.servlet.ServletContextStub;
public class TagsTest extends AbstractTestClass {
private ServletContextStub ctx;
private File resource;
@Before
public void setup() throws IOException {
resource = File.createTempFile("resource", "");
ctx = new ServletContextStub();
ctx.setRealPath("/base/sub/file.js", resource.getPath());
ctx.setRealPath("/base/sub/file.css", resource.getPath());
ApplicationStub.setup(ctx, null);
setContextPath("/context");
}
// ----------------------------------------------------------------------
// Parsing tests
//
// Reference for parsing attributes:
// https://www.w3.org/TR/html/syntax.html#elements-attributes
// ----------------------------------------------------------------------
@Test
public void noAttribute_failure() {
assertNoMatch("<div height='value'></div>");
}
@Test
public void singleQuote_noTerminator_failure() {
assertNoMatch("<link rel='stylesheet' href='problem></link>");
}
@Test
public void singleQuotes_embeddedSingleQuote_failure() {
assertNoMatch("<script src='value'problem'></script");
}
@Test
public void singleQuotes_embeddedDoubleQuote_success() {
assertMatch("<script src='value\"noproblem'></script",
"value\"noproblem");
}
@Test
public void doubleQuote_noTerminator_failure() {
assertNoMatch("<link rel=\"stylesheet\" href=\"problem ></link>");
}
@Test
public void doubleQuotes_embeddedDoubleQuote_failure() {
assertNoMatch("<link href=\"value\"problem\"></link>");
}
@Test
public void doubleQuotes_embeddedSingleQuote_success() {
assertMatch("<link href=\"value'noproblem\"></link>",
"value'noproblem");
}
@Test
public void unquotedBadTerminator_failure() {
assertNoMatch("<script src=problem");
}
@Test
public void unquoted_embeddedEquals_failure() {
assertNoMatch("<script src=value=problem>");
}
@Test
public void unquoted_embeddedSingleQuote_failure() {
assertNoMatch("<script src=value'problem>");
}
@Test
public void unquoted_embeddedDoubleQuote_failure() {
assertNoMatch("<script src=value\"problem>");
}
@Test
public void unquoted_embeddedBackTick_failure() {
assertNoMatch("<script src=value`problem>");
}
@Test
public void unquoted_embeddedLessThan_failure() {
assertNoMatch("<script src=value<problem>");
}
@Test
public void spacesBeforeEquals_success() {
assertMatch("<link href =value rel='stylesheet'>", "value");
}
@Test
public void spacesAfterEquals_success() {
assertMatch("<script src= 'value'></script>", "value");
}
@Test
public void noSpacesAroundEquals_success() {
assertMatch("<script src=\"value\" ></script>", "value");
}
// ----------------------------------------------------------------------
// Substitution tests
// ----------------------------------------------------------------------
@Test
public void noMatch_noChange() {
assertVersionNotAdded(
"<script junk='/context/base/sub/file.js' ></script>",
"no match");
}
@Test
public void alreadyHasQueryString_noChange() {
assertVersionNotAdded(
"<script src='/context/base/sub/file.js?why' ></script>",
"has query");
}
@Test
public void doesntStartWithContextPath_noChange() {
assertVersionNotAdded(
"<script src='/notContext/base/sub/file.js' ></script>",
"context path");
}
@Test
public void noRealPath_noChange() {
assertVersionNotAdded(
"<script src='/context/base/sub/nofile.js' ></script>",
"real path");
}
@Test
@Ignore
public void exception_noChange() {
fail("exception_noChange not implemented");
}
@Test
public void doubleQuotes_substitution() {
assertVersionAdded( //
"<link href=\"/context/base/sub/file.css\" rel=stylesheet></link>", //
"<link href=\"/context/base/sub/file.css?version=9999\" rel=stylesheet></link>");
}
@Test
public void singleQuotes_substitution() {
assertVersionAdded( //
"<script src='/context/base/sub/file.js' ></script>", //
"<script src='/context/base/sub/file.js?version=9999' ></script>");
}
@Test
public void unquoted_substitution() {
assertVersionAdded( //
"<script type=text/javascript src=/context/base/sub/file.js ></script>", //
"<script type=text/javascript src=/context/base/sub/file.js?version&eq;9999 ></script>");
}
// ----------------------------------------------------------------------
// Helper methods
// ----------------------------------------------------------------------
private void setContextPath(String contextPath) {
try {
Field f = UrlBuilder.class.getDeclaredField("contextPath");
f.setAccessible(true);
f.set(null, contextPath);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private void assertMatch(String tag, String expected) {
TagVersionInfo info = new TagVersionInfo(tag);
try {
Field f = TagVersionInfo.class.getDeclaredField("match");
f.setAccessible(true);
MatchResult match = (MatchResult) f.get(info);
assertEquals(expected, match.group);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private void assertNoMatch(String tag) {
TagVersionInfo info = new TagVersionInfo(tag);
assertFalse(info.hasVersion());
}
private void assertVersionAdded(String rawTag, String expected) {
String actual = createTag(rawTag);
String canonicalActual = actual.replaceAll("=[0-9a-f]{4}", "=9999")
.replaceAll("&eq;[0-9a-f]{4}", "&eq;9999");
assertEquals(expected, canonicalActual);
}
private void assertVersionNotAdded(String rawTag, String debugMessage) {
StringWriter writer = new StringWriter();
captureLogOutput(Tags.class, writer, true);
setLoggerLevel(Tags.class, Level.DEBUG);
String actual = createTag(rawTag);
assertEquals(rawTag, actual);
assertThat(writer.toString(), containsString(debugMessage));
}
private String createTag(String rawTag) {
Tags t = new Tags();
t.add(rawTag);
return t.list();
}
}