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:
parent
0e15b9a69a
commit
b00633326e
2 changed files with 497 additions and 64 deletions
|
@ -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 + "]";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Reference in a new issue