/* * #%L * ACS AEM Commons Bundle * %% * Copyright (C) 2013 Adobe * %% * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * #L% */ package com.adobe.acs.commons.rewriter.impl; import java.io.IOException; import java.nio.charset.Charset; import java.util.Dictionary; import java.util.Hashtable; import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.management.DynamicMBean; import javax.management.NotCompliantMBeanException; import javax.management.openmbean.CompositeType; import javax.management.openmbean.OpenDataException; import javax.management.openmbean.OpenType; import javax.management.openmbean.SimpleType; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletResponse; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.turbo.TurboFilter; import ch.qos.logback.core.spi.FilterReply; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.lang.StringUtils; import org.apache.felix.scr.annotations.Activate; import org.apache.felix.scr.annotations.Component; import org.apache.felix.scr.annotations.Deactivate; import org.apache.felix.scr.annotations.Properties; import org.apache.felix.scr.annotations.Property; import org.apache.felix.scr.annotations.Reference; import org.apache.felix.scr.annotations.Service; import org.apache.sling.api.SlingConstants; import org.apache.sling.api.SlingHttpServletRequest; import org.apache.sling.api.SlingHttpServletResponse; import org.apache.sling.api.resource.ResourceResolver; import org.apache.sling.commons.osgi.PropertiesUtil; import org.apache.sling.rewriter.ProcessingComponentConfiguration; import org.apache.sling.rewriter.ProcessingContext; import org.apache.sling.rewriter.Transformer; import org.apache.sling.rewriter.TransformerFactory; import org.osgi.framework.BundleContext; import org.osgi.framework.ServiceRegistration; import org.osgi.service.component.ComponentContext; import org.osgi.service.event.Event; import org.osgi.service.event.EventConstants; import org.osgi.service.event.EventHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.Marker; import org.xml.sax.Attributes; import org.xml.sax.SAXException; import org.xml.sax.helpers.AttributesImpl; import com.adobe.acs.commons.rewriter.AbstractTransformer; import com.adobe.acs.commons.util.impl.AbstractGuavaCacheMBean; import com.adobe.acs.commons.util.impl.GenericCacheMBean; import com.adobe.granite.ui.clientlibs.HtmlLibrary; import com.adobe.granite.ui.clientlibs.HtmlLibraryManager; import com.adobe.granite.ui.clientlibs.LibraryType; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; /** * ACS AEM Commons - Versioned Clientlibs (CSS/JS) Rewriter * Re-writes paths to CSS and JS clientlibs to include the md5 checksum as a " * selector; in the form: /path/to/clientlib.123456789.css or /path/to/clientlib.min.1234589.css (if minification is enabled) * If the Enforce MD5 filter is enabled, the paths will be like /path/to/clientlib.ACSHASH123456789.css or /path/to/clientlib.min.ACSHASH1234589.css (if minification is enabled) */ @Component(metatype = true, label = "ACS AEM Commons - Versioned Clientlibs Transformer Factory", description = "Sling Rewriter Transformer Factory to add auto-generated checksums to client library references") @Properties({ @Property(name = "pipeline.type", value = "versioned-clientlibs", propertyPrivate = true), @Property(name = EventConstants.EVENT_TOPIC, value = "com/adobe/granite/ui/librarymanager/INVALIDATED", propertyPrivate = true), @Property(name = "jmx.objectname", value = "com.adobe.acs.commons.rewriter:type=VersionedClientlibsTransformerMd5Cache", propertyPrivate = true) }) @Service(value = {DynamicMBean.class, TransformerFactory.class, EventHandler.class}) public final class VersionedClientlibsTransformerFactory extends AbstractGuavaCacheMBean<VersionedClientLibraryMd5CacheKey, String> implements TransformerFactory, EventHandler, GenericCacheMBean { private static final Logger log = LoggerFactory.getLogger(VersionedClientlibsTransformerFactory.class); private static final int DEFAULT_MD5_CACHE_SIZE = 300; private static final boolean DEFAULT_DISABLE_VERSIONING = false; private static final boolean DEFAULT_ENFORCE_MD5 = false; @Property(label="MD5 Cache Size", description="Maximum size of the md5 cache.", intValue = DEFAULT_MD5_CACHE_SIZE) private static final String PROP_MD5_CACHE_SIZE = "md5cache.size"; @Property(label="Disable Versioning", description="Should versioning of clientlibs be disabled", boolValue = DEFAULT_DISABLE_VERSIONING) private static final String PROP_DISABLE_VERSIONING = "disable.versioning"; @Property(label="Enforce MD5", description="Enables a filter which returns a 404 error if the MD5 in the request does not match the expected value", boolValue = DEFAULT_ENFORCE_MD5) private static final String PROP_ENFORCE_MD5 = "enforce.md5"; private static final String ATTR_JS_PATH = "src"; private static final String ATTR_CSS_PATH = "href"; private static final String MIN_SELECTOR = "min"; private static final String MIN_SELECTOR_SEGMENT = "." + MIN_SELECTOR; private static final String MD5_PREFIX = "ACSHASH"; // pattern used to parse paths in the filter - group 1 = path; group 2 = md5; group 3 = extension private static final Pattern FILTER_PATTERN = Pattern.compile("(.*?)\\.(?:min.)?" + MD5_PREFIX + "([a-zA-Z0-9]+)\\.(js|css)"); private static final String PROXY_PREFIX = "/etc.clientlibs/"; private Cache<VersionedClientLibraryMd5CacheKey, String> md5Cache; private boolean disableVersioning; private boolean enforceMd5; @Reference private HtmlLibraryManager htmlLibraryManager; private ServiceRegistration filterReg; public VersionedClientlibsTransformerFactory() throws NotCompliantMBeanException { super(GenericCacheMBean.class); } @Activate protected void activate(ComponentContext componentContext) { final BundleContext bundleContext = componentContext.getBundleContext(); final Dictionary<?, ?> props = componentContext.getProperties(); final int size = PropertiesUtil.toInteger(props.get(PROP_MD5_CACHE_SIZE), DEFAULT_MD5_CACHE_SIZE); this.md5Cache = CacheBuilder.newBuilder().recordStats().maximumSize(size).build(); this.disableVersioning = PropertiesUtil.toBoolean(props.get(PROP_DISABLE_VERSIONING), DEFAULT_DISABLE_VERSIONING); this.enforceMd5 = PropertiesUtil.toBoolean(props.get(PROP_ENFORCE_MD5), DEFAULT_ENFORCE_MD5); if (enforceMd5) { Dictionary<String, Object> filterProps = new Hashtable<String, Object>(); filterProps.put("sling.filter.scope", "REQUEST"); filterProps.put("service.ranking", Integer.valueOf(0)); filterReg = bundleContext.registerService(Filter.class.getName(), new BadMd5VersionedClientLibsFilter(), filterProps); } } @Deactivate protected void deactivate() { this.md5Cache = null; if (filterReg != null) { filterReg.unregister();; filterReg = null; } } public Transformer createTransformer() { return new VersionableClientlibsTransformer(); } private Attributes versionClientLibs(final String elementName, final Attributes attrs, final SlingHttpServletRequest request) { if (SAXElementUtils.isCSS(elementName, attrs)) { return this.rebuildAttributes(new AttributesImpl(attrs), attrs.getIndex("", ATTR_CSS_PATH), attrs.getValue("", ATTR_CSS_PATH), LibraryType.CSS, request); } else if (SAXElementUtils.isJavaScript(elementName, attrs)) { return this.rebuildAttributes(new AttributesImpl(attrs), attrs.getIndex("", ATTR_JS_PATH), attrs.getValue("", ATTR_JS_PATH), LibraryType.JS, request); } else { return attrs; } } private Attributes rebuildAttributes(final AttributesImpl newAttributes, final int index, final String path, final LibraryType libraryType, final SlingHttpServletRequest request) { final String contextPath = request.getContextPath(); String libraryPath = path; if (StringUtils.isNotBlank(contextPath)) { libraryPath = path.substring(contextPath.length()); } String versionedPath = this.getVersionedPath(libraryPath, libraryType, request.getResourceResolver()); if (StringUtils.isNotBlank(versionedPath)) { if(StringUtils.isNotBlank(contextPath)) { versionedPath = contextPath + versionedPath; } log.debug("Rewriting to: {}", versionedPath); newAttributes.setValue(index, versionedPath); } else { log.debug("Versioned Path could not be created properly"); } return newAttributes; } private String getVersionedPath(final String originalPath, final LibraryType libraryType, final ResourceResolver resourceResolver) { try { boolean appendMinSelector = false; String libraryPath = StringUtils.substringBeforeLast(originalPath, "."); if (libraryPath.endsWith(MIN_SELECTOR_SEGMENT)) { appendMinSelector = true; libraryPath = StringUtils.substringBeforeLast(libraryPath, "."); } final HtmlLibrary htmlLibrary = getLibrary(libraryType, libraryPath, resourceResolver); if (htmlLibrary != null) { StringBuilder builder = new StringBuilder(); builder.append(libraryPath); builder.append("."); if (appendMinSelector) { builder.append(MIN_SELECTOR).append("."); } if (enforceMd5) { builder.append(MD5_PREFIX); } builder.append(getMd5(htmlLibrary)); builder.append(libraryType.extension); return builder.toString(); } else { log.debug("Could not find HtmlLibrary at path: {}", libraryPath); return null; } } catch (Exception ex) { // Handle unexpected formats of the original path log.error("Attempting to get a versioned path for [ {} ] but could not because of: {}", originalPath, ex.getMessage()); return originalPath; } } private HtmlLibrary getLibrary(LibraryType libraryType, String libraryPath, ResourceResolver resourceResolver) { HtmlLibrary htmlLibrary = null; if (libraryPath.startsWith(PROXY_PREFIX)) { final String relativePath = libraryPath.substring(PROXY_PREFIX.length()); for (final String prefix : resourceResolver.getSearchPath()) { final String absolutePath = prefix + relativePath; htmlLibrary = htmlLibraryManager.getLibrary(libraryType, absolutePath); if (htmlLibrary != null) { break; } } } else { htmlLibrary = htmlLibraryManager.getLibrary(libraryType, libraryPath); } return htmlLibrary; } @Nonnull private String getMd5(@Nonnull final HtmlLibrary htmlLibrary) throws IOException, ExecutionException { return md5Cache.get(new VersionedClientLibraryMd5CacheKey(htmlLibrary), new Callable<String>() { @Override public String call() throws Exception { return calculateMd5(htmlLibrary); } }); } @Nonnull private String calculateMd5(@Nonnull final HtmlLibrary htmlLibrary) throws IOException { return DigestUtils.md5Hex(htmlLibrary.getInputStream()); } private class VersionableClientlibsTransformer extends AbstractTransformer { private SlingHttpServletRequest request; @Override public void init(ProcessingContext context, ProcessingComponentConfiguration config) throws IOException { super.init(context, config); this.request = context.getRequest(); } public void startElement(final String namespaceURI, final String localName, final String qName, final Attributes attrs) throws SAXException { final Attributes nextAttributes; if (disableVersioning) { nextAttributes = attrs; } else { nextAttributes = versionClientLibs(localName, attrs, request); } getContentHandler().startElement(namespaceURI, localName, qName, nextAttributes); } } @Override public void handleEvent(Event event) { String path = (String) event.getProperty(SlingConstants.PROPERTY_PATH); md5Cache.invalidate(new VersionedClientLibraryMd5CacheKey(path, LibraryType.JS)); md5Cache.invalidate(new VersionedClientLibraryMd5CacheKey(path, LibraryType.CSS)); } @Override protected Cache<VersionedClientLibraryMd5CacheKey, String> getCache() { return md5Cache; } @Override protected long getBytesLength(String cacheObj) { return cacheObj.getBytes(Charset.forName("UTF-8")).length; } @Override protected void addCacheData(Map<String, Object> data, String cacheObj) { data.put("Value", cacheObj); } @Override protected String toString(String cacheObj) throws Exception { return cacheObj; } @Override protected CompositeType getCacheEntryType() throws OpenDataException { return new CompositeType("Cache Entry", "Cache Entry", new String[] { "Cache Key", "Value" }, new String[] { "Cache Key", "Value" }, new OpenType[] { SimpleType.STRING, SimpleType.STRING }); } @Nonnull UriInfo getUriInfo(@Nullable final String uri, @Nonnull ResourceResolver resourceResolver) { if (uri != null) { Matcher matcher = FILTER_PATTERN.matcher(uri); if (matcher.matches()) { final String libraryPath = matcher.group(1); final String md5 = matcher.group(2); final String extension = matcher.group(3); LibraryType libraryType; if (LibraryType.CSS.extension.substring(1).equals(extension)) { libraryType = LibraryType.CSS; } else { libraryType = LibraryType.JS; } final HtmlLibrary htmlLibrary = getLibrary(libraryType, libraryPath, resourceResolver); return new UriInfo(libraryPath + "." + extension, md5, libraryType, htmlLibrary); } } return new UriInfo("", "", null, null); } class BadMd5VersionedClientLibsFilter implements Filter { @Override public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain filterChain) throws IOException, ServletException { if (request instanceof SlingHttpServletRequest && response instanceof SlingHttpServletResponse) { final SlingHttpServletRequest slingRequest = (SlingHttpServletRequest) request; final SlingHttpServletResponse slingResponse = (SlingHttpServletResponse) response; String uri = slingRequest.getRequestURI(); UriInfo uriInfo = getUriInfo(uri, slingRequest.getResourceResolver()); if (uriInfo.cacheKey != null) { if ("".equals(uriInfo.md5)) { log.debug("MD5 is blank for '{}' in Versioned ClientLibs cache, allowing {} to pass", uriInfo.cleanedUri, uri); filterChain.doFilter(request, response); return; } String md5FromCache = null; try { md5FromCache = getCacheEntry(uriInfo.cacheKey); } catch (Exception e) { md5FromCache = null; } // this static value "Invalid cache key parameter." happens when the cache key can't be // found in the cache if ("Invalid cache key parameter.".equals(md5FromCache)) { md5FromCache = calculateMd5(uriInfo.htmlLibrary); } if (md5FromCache == null) { // something went bad during the cache access log.warn("Failed to fetch data from Versioned ClientLibs cache, allowing {} to pass", uri); filterChain.doFilter(request, response); } else { // the file is in the cache, compare the md5 from cache with the one in the request if (md5FromCache.equals(uriInfo.md5)) { log.debug("MD5 equals for '{}' in Versioned ClientLibs cache, allowing {} to pass", uriInfo.cleanedUri, uri); filterChain.doFilter(request, response); } else { log.info("MD5 differs for '{}' in Versioned ClientLibs cache. Expected {}. Sending 404 for '{}'", new Object[] { uriInfo.cleanedUri, md5FromCache, uri }); slingResponse.sendError(HttpServletResponse.SC_NOT_FOUND); } } } else { filterChain.doFilter(request, response); } } else { filterChain.doFilter(request, response); } } @Override public void init(final FilterConfig filterConfig) throws ServletException {} @Override public void destroy() {} } static class UriInfo { private final String cleanedUri; private final String md5; private final LibraryType libraryType; private final HtmlLibrary htmlLibrary; private final String cacheKey; UriInfo(String cleanedUri, String md5, LibraryType libraryType, HtmlLibrary htmlLibrary) { this.cleanedUri = cleanedUri; this.md5 = md5; this.libraryType = libraryType; this.htmlLibrary = htmlLibrary; if (libraryType != null && htmlLibrary != null) { cacheKey = htmlLibrary.getLibraryPath() + libraryType.extension; } else { cacheKey = null; } } } }