diff --git a/api/src/main/java/edu/cornell/mannlib/vitro/webapp/web/templatemodels/Tags.java b/api/src/main/java/edu/cornell/mannlib/vitro/webapp/web/templatemodels/Tags.java index a51e0c541..d1bfa5bd6 100644 --- a/api/src/main/java/edu/cornell/mannlib/vitro/webapp/web/templatemodels/Tags.java +++ b/api/src/main/java/edu/cornell/mannlib/vitro/webapp/web/templatemodels/Tags.java @@ -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 tags; + private static final Log log = LogFactory.getLog(Tags.class); - public Tags() { - this.tags = new LinkedHashSet(); - } - - public Tags(LinkedHashSet 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 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(); + } + public Tags(LinkedHashSet 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 + "]"; + } + } + } } diff --git a/api/src/test/java/edu/cornell/mannlib/vitro/webapp/web/templatemodels/TagsTest.java b/api/src/test/java/edu/cornell/mannlib/vitro/webapp/web/templatemodels/TagsTest.java new file mode 100644 index 000000000..11fadb585 --- /dev/null +++ b/api/src/test/java/edu/cornell/mannlib/vitro/webapp/web/templatemodels/TagsTest.java @@ -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("
"); + } + + @Test + public void singleQuote_noTerminator_failure() { + assertNoMatch(""); + } + + @Test + public void doubleQuotes_embeddedDoubleQuote_failure() { + assertNoMatch(""); + } + + @Test + public void doubleQuotes_embeddedSingleQuote_success() { + assertMatch("", + "value'noproblem"); + } + + @Test + public void unquotedBadTerminator_failure() { + assertNoMatch("", "value"); + } + + @Test + public void noSpacesAroundEquals_success() { + assertMatch("", "value"); + } + + // ---------------------------------------------------------------------- + // Substitution tests + // ---------------------------------------------------------------------- + + @Test + public void noMatch_noChange() { + assertVersionNotAdded( + "", + "no match"); + } + + @Test + public void alreadyHasQueryString_noChange() { + assertVersionNotAdded( + "", + "has query"); + } + + @Test + public void doesntStartWithContextPath_noChange() { + assertVersionNotAdded( + "", + "context path"); + } + + @Test + public void noRealPath_noChange() { + assertVersionNotAdded( + "", + "real path"); + } + + @Test + @Ignore + public void exception_noChange() { + fail("exception_noChange not implemented"); + } + + @Test + public void doubleQuotes_substitution() { + assertVersionAdded( // + "", // + ""); + } + + @Test + public void singleQuotes_substitution() { + assertVersionAdded( // + "", // + ""); + } + + @Test + public void unquoted_substitution() { + assertVersionAdded( // + "", // + ""); + } + + // ---------------------------------------------------------------------- + // 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(); + } + +} \ No newline at end of file