/* * The MIT License * * Copyright 2013 Tim Boudreau. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ package com.mastfrog.acteur; import com.mastfrog.acteur.headers.Headers; import com.google.common.net.MediaType; import com.google.inject.Inject; import com.mastfrog.url.Path; import com.mastfrog.util.Streams; import com.mastfrog.util.streams.HashingInputStream; import com.mastfrog.acteur.ResponseHeaders.ContentLengthProvider; import com.mastfrog.acteur.ResponseHeaders.ETagProvider; import com.mastfrog.acteur.util.CacheControlTypes; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.util.CharsetUtil; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.lang.reflect.Method; import java.net.URLDecoder; import java.util.HashMap; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.codec.binary.Base64; import org.joda.time.DateTime; import org.joda.time.Duration; /** * A page which loads resources relative to itself on the classpath. Handles * caching headers as follows: ETag is generated (SHA-1) on first read; last * modified = server start time. * <p/> * Use this to embed resources inside application JARs - this is not for * serving flat files on disk. * * @author Tim Boudreau */ public abstract class ClasspathResourcePage extends Page implements ContentLengthProvider, ETagProvider { private static Map<Class<?>, Map<Path, Integer>> sizes = new HashMap<>(); private static Map<Class<?>, Boolean> overridesProcessContent = new HashMap<>(); private static Map<Class<?>, Map<Path, String>> etags = new HashMap<>(); private static final Map<Class<?>, Map<Path, byte[]>> contentForPathForType = new HashMap<>(); private final Path path; protected ClasspathResourcePage(final HttpEvent event, ActeurFactory f, DateTime serverStartTime, String... patterns) { this (null, event, f, serverStartTime, patterns); } @Deprecated @SuppressWarnings("LeakingThisInConstructor") protected ClasspathResourcePage(final Application app, final HttpEvent event, ActeurFactory f, DateTime serverStartTime, String... patterns) { this.path = event.getPath(); responseHeaders.setLastModified(serverStartTime); responseHeaders.addCacheControl(CacheControlTypes.Public); responseHeaders.addCacheControl(CacheControlTypes.must_revalidate); responseHeaders.addCacheControl(CacheControlTypes.max_age, Duration.standardDays(100)); responseHeaders.setContentLengthProvider(this); responseHeaders.setETagProvider(this); getResponseHeaders().setMaxAge(Duration.standardDays(100)); getResponseHeaders().addVaryHeader(Headers.CONTENT_ENCODING); add(f.matchPath(patterns)); add(f.matchMethods(com.mastfrog.acteur.headers.Method.GET, com.mastfrog.acteur.headers.Method.HEAD)); add(HasStreamAction.class); add(f.sendNotModifiedIfETagHeaderMatches()); add(f.sendNotModifiedIfIfModifiedSinceHeaderMatches()); if (event.getMethod() != com.mastfrog.acteur.headers.Method.HEAD) { add(WriteBodyActeur.class); } else { add(f.responseCode(HttpResponseStatus.OK)); } } protected MediaType getContentType(Path path) { MediaType type = responseHeaders.getContentType(); if (type != null) { return type; } String pth = path.toString(); if (pth.endsWith("svg")) { return MediaType.SVG_UTF_8; } else if (pth.endsWith("css")) { return MediaType.CSS_UTF_8; } else if (pth.endsWith("html")) { return MediaType.HTML_UTF_8; } else if (pth.endsWith("json")) { return MediaType.JSON_UTF_8; } else if (pth.endsWith("js")) { return MediaType.JAVASCRIPT_UTF_8; } else if (pth.endsWith("gif")) { return MediaType.GIF; } else if (pth.endsWith("jpg")) { return MediaType.JPEG; } else if (pth.endsWith("png")) { return MediaType.PNG; } return null; } private static class WriteBodyActeur extends Acteur { @Inject @SuppressWarnings("ArrayIsStoredDirectly") WriteBodyActeur(HttpEvent event, Page page) throws IOException { byte[] content = ((ClasspathResourcePage) page).getContent(event.getPath()); setState(new RespondWith(HttpResponseStatus.OK)); setResponseWriter(new BodyWriter(content, event.isKeepAlive())); } } private static class HasStreamAction extends Acteur { @Inject HasStreamAction(Page page, HttpEvent event) { boolean hasContent; Map<Path, byte[]> m = contentForPathForType.get(page.getClass()); hasContent = (m != null && m.containsKey(event.getPath()) || getStream(event.getPath(), page.getClass()) != null); if (hasContent) { String cachedEtag = getCachedEtag(page.getClass(), event.getPath()); if (cachedEtag != null) { page.getResponseHeaders().setEtag(cachedEtag); } Long cachedSize = getCachedSize(page.getClass(), event.getPath()); if (cachedSize != null) { add(Headers.CONTENT_LENGTH, cachedSize); } setState(new ConsumedState()); } else { setState(new RespondWith(HttpResponseStatus.NOT_FOUND, "No such page " + event.getPath())); } } } private static Long getCachedSize(Class<?> pageClass, Path path) { Map<Path, Integer> sz = sizes.get(pageClass); if (sz != null) { Integer val = sz.get(path); if (val != null) { return val.longValue(); } } return null; } private static String getCachedEtag(Class<?> pageClass, Path path) { Map<Path, String> tags = etags.get(pageClass); if (tags != null) { return tags.get(path); } return null; } InputStream getStream(Path path) { return getStream(path, getClass()); } protected static InputStream getStream(Path path, Class<?> type) { try { String name = URLDecoder.decode(path.getLastElement().toString(), "UTF-8"); InputStream in = type.getResourceAsStream(name); return in; } catch (UnsupportedEncodingException ex) { throw new AssertionError(ex); //won't happen } } static class BodyWriter extends ResponseWriter { private final byte[] bytes; private volatile int offset = 0; private int chunksize = 256; private final boolean keepAlive; @SuppressWarnings("ArrayIsStoredDirectly") BodyWriter(byte[] content, boolean keepAlive) { bytes = content; this.keepAlive = keepAlive; } @Override public Status write(Event<?> evt, Output out, int iteration) throws Exception { int old = offset; int remaining = Math.min(chunksize, bytes.length - offset); offset += remaining; ByteBuf buf = Unpooled.wrappedBuffer(bytes, old, remaining); out.write(buf); return offset < bytes.length ? Status.NOT_DONE : Status.DONE; } } @Override public Long getContentLength() { long result = -1; if (!isDynamicContent()) { Map<Path, Integer> m = sizes.get(getClass()); if (m == null) { sizes.put(getClass(), m = new HashMap<>()); } getETag(); Integer res = m.get(path); if (res != null) { result = res; } } return result == -1 ? null : result; } private boolean shouldCache(Path path) { return !isDynamicContent(); } protected byte[] getContent(Path path) throws IOException { boolean cache = shouldCache(path); Map<Path, byte[]> cacheMap = contentForPathForType.get(getClass()); if (cache && cacheMap != null) { byte[] res = cacheMap.get(path); if (res != null) { return res; } } else if (cache) { cacheMap = new HashMap<>(); contentForPathForType.put(getClass(), cacheMap); } InputStream in = getStream(path); if (in == null) { return null; } byte[] result; if (!isDynamicContent()) { Map<Path, String> m = etags.get(getClass()); String etag = null; if (m == null) { m = new HashMap<>(); etags.put(getClass(), m); } else { etag = m.get(path); } if (etag == null) { HashingInputStream hin = HashingInputStream.sha1(in); ByteArrayOutputStream o = new ByteArrayOutputStream(); int byteCount = Streams.copy(hin, o); hin.close(); m.put(path, getHashString(hin)); Map<Path, Integer> sz = sizes.get(getClass()); if (sz == null) { sz = new HashMap<>(); sizes.put(getClass(), sz); } sz.put(path, byteCount); result = o.toByteArray(); } else { try { ByteArrayOutputStream o = new ByteArrayOutputStream(); Streams.copy(in, o); result = o.toByteArray(); } finally { in.close(); } } } else { try { ByteArrayOutputStream o = new ByteArrayOutputStream(); Streams.copy(in, o); result = o.toByteArray(); } finally { in.close(); } } if (cache) { cacheMap.put(path, result); } return result; } protected byte[] processContent(byte[] content) { return content; } private static Method findMethod(Class<?> on, String name, Class<?>... params) throws SecurityException { Class<?> curr = on; //NOTE: Does not check interfaces while (curr != Object.class) { try { return curr.getDeclaredMethod(name, params); } catch (NoSuchMethodException ex) { // Logger.getLogger(ClasspathResourcePage.class.getName()).log(Level.SEVERE, null, ex); } finally { curr = curr.getSuperclass(); } } return null; } protected boolean isDynamicContent() { Boolean dynContent = overridesProcessContent.get(getClass()); if (dynContent == null) { try { Method m = findMethod(getClass(), "processContent", String.class); dynContent = m == null ? false : m.getDeclaringClass() == ClasspathResourcePage.class; overridesProcessContent.put(getClass(), dynContent); } catch (SecurityException ex) { Logger.getLogger(ClasspathResourcePage.class.getName()).log(Level.SEVERE, null, ex); dynContent = true; } } return dynContent; } @Override public String getETag() { if (isDynamicContent()) { return null; } Map<Path, String> m = etags.get(getClass()); if (m == null) { m = new HashMap<>(); etags.put(getClass(), m); } String etag = m.get(path); if (etag == null) { InputStream in = getStream(path); if (in == null) { return null; } HashingInputStream hin = HashingInputStream.sha1(in); try { int byteCount = Streams.copy(hin, Streams.nullOutputStream()); etag = getHashString(hin); Map<Path, Integer> sz = sizes.get(getClass()); if (sz == null) { sz = new HashMap<>(); sizes.put(getClass(), sz); } sz.put(path, byteCount); m.put(path, etag); } catch (IOException ex) { Logger.getLogger(ClasspathResourcePage.class.getName()).log(Level.SEVERE, null, ex); } } return etag; } String getHashString(HashingInputStream hin) throws IOException { hin.close(); byte[] bytes = hin.getDigest(); byte[] base64 = Base64.encodeBase64(bytes); return new String(base64, CharsetUtil.US_ASCII); } }