/* * 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.resources; import com.google.common.net.MediaType; import com.google.inject.Inject; import com.google.inject.Singleton; import com.mastfrog.acteur.Event; import com.mastfrog.acteur.HttpEvent; import com.mastfrog.acteur.Page; import com.mastfrog.acteur.Response; import com.mastfrog.acteur.ResponseHeaders; import com.mastfrog.acteur.ResponseHeaders.ContentLengthProvider; import com.mastfrog.acteur.ResponseWriter; import com.mastfrog.acteur.util.CacheControlTypes; import com.mastfrog.acteur.headers.Headers; import static com.mastfrog.acteur.resources.FileResources.RESOURCES_BASE_PATH; import com.mastfrog.giulius.DeploymentMode; import com.mastfrog.settings.Settings; import com.mastfrog.util.Checks; import com.mastfrog.util.Streams; import com.mastfrog.util.Strings; import com.mastfrog.util.streams.HashingOutputStream; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufAllocator; import io.netty.buffer.ByteBufInputStream; import io.netty.buffer.ByteBufOutputStream; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.compression.JZlibDecoder; import io.netty.handler.codec.compression.ZlibWrapper; import io.netty.handler.codec.http.DefaultHttpContent; import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.LastHttpContent; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.net.URLDecoder; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.zip.GZIPOutputStream; import org.joda.time.DateTime; import org.joda.time.Duration; /** * * @author Tim Boudreau */ @Singleton public final class ClasspathResources implements StaticResources { private final MimeTypes types; private final Class<?> relativeTo; private final Map<String, Resource> names = new HashMap<>(); private static final DateTime startTime = DateTime.now(); private final String[] patterns; private final DeploymentMode mode; private final ByteBufAllocator allocator; private final boolean internalGzip; @Inject public ClasspathResources(MimeTypes types, ClasspathResourceInfo info, DeploymentMode mode, ByteBufAllocator allocator, Settings settings) throws Exception { Checks.notNull("allocator", allocator); Checks.notNull("types", types); Checks.notNull("info", info); Checks.notNull("mode", mode); this.allocator = allocator; internalGzip = settings.getBoolean("internal.gzip", false); this.types = types; this.mode = mode; this.relativeTo = info.relativeTo(); List<String> l = new ArrayList<>(); String resourcesBasePath = settings.getString(RESOURCES_BASE_PATH, ""); for (String nm : info.names()) { this.names.put(nm, new ClasspathResource(nm)); String pat = Strings.join(resourcesBasePath, nm); l.add(pat); } patterns = l.toArray(new String[0]); } boolean productionMode() { return mode.isProduction(); } public Resource get(String path) { if (path.indexOf('%') >= 0) { path = URLDecoder.decode(path); } return names.get(path); } public String[] getPatterns() { return patterns; } static void gzip(ByteBuf in, ByteBuf out) throws IOException { try (GZIPOutputStream outStream = new GZIPOutputStream(new ByteBufOutputStream(out), 9)) { try (ByteBufInputStream inStream = new ByteBufInputStream(in)) { Streams.copy(inStream, outStream, 512); } } } static class Y extends JZlibDecoder { Y() { super(ZlibWrapper.GZIP); super.setSingleDecode(true); } @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { while (in.readableBytes() > 0) { super.decode(ctx, in, out); } } } private class ClasspathResource implements Resource, ContentLengthProvider { private final ByteBuf bytes; private final ByteBuf compressed; private final String hash; private final String name; private final int length; ClasspathResource(String name) throws Exception { Checks.notNull("name", name); this.name = name; ByteBuf bytes = allocator.directBuffer(); try (InputStream in = relativeTo.getResourceAsStream(name)) { if (in == null) { throw new FileNotFoundException(name); } try (ByteBufOutputStream out = new ByteBufOutputStream(bytes)) { try (HashingOutputStream hashOut = HashingOutputStream.sha1(out)) { Streams.copy(in, hashOut, 512); hash = hashOut.getHashAsString(); } } } bytes.retain(); this.bytes = Unpooled.unreleasableBuffer(bytes); if (internalGzip) { int sizeEstimate = (int) Math.ceil(bytes.readableBytes() * 1.001) + 12; ByteBuf compressed = allocator.directBuffer(sizeEstimate); gzip(bytes, compressed); bytes.resetReaderIndex(); this.compressed = Unpooled.unreleasableBuffer(compressed); assert check(); } else { compressed = null; } bytes.resetReaderIndex(); length = bytes.readableBytes(); } private boolean check() throws Exception { Y y = new Y(); ByteBuf test = allocator.buffer(bytes.readableBytes()); try { y.decode(null, compressed, Collections.<Object>singletonList(test)); compressed.resetReaderIndex(); byte[] a = new byte[bytes.readableBytes()]; bytes.readBytes(a); byte[] b = new byte[test.readableBytes()]; test.readBytes(b); if (!Arrays.equals(a, b)) { throw new IllegalStateException("Compressed data differs. Orig length " + a.length + " result length " + b.length + "\n. ORIG:\n" + new String(a) + "\n\nNEW:\n" + new String(b)); } bytes.resetReaderIndex(); } finally { test.release(); } return true; } @Override public void decoratePage(Page page, HttpEvent evt, String path, Response response, boolean chunked) { ResponseHeaders h = page.getResponseHeaders(); String ua = evt.getHeader("User-Agent"); if (ua != null && !ua.contains("MSIE")) { page.getResponseHeaders().addVaryHeader(Headers.ACCEPT_ENCODING); } if (productionMode()) { page.getResponseHeaders().addCacheControl(CacheControlTypes.Public); page.getResponseHeaders().addCacheControl(CacheControlTypes.max_age, Duration.standardHours(2)); page.getResponseHeaders().addCacheControl(CacheControlTypes.must_revalidate); } else { page.getResponseHeaders().addCacheControl(CacheControlTypes.Private); page.getResponseHeaders().addCacheControl(CacheControlTypes.no_cache); page.getResponseHeaders().addCacheControl(CacheControlTypes.no_store); } // if (evt.getMethod() != Method.HEAD) { // page.getReponseHeaders().setContentLengthProvider(this); // } h.setLastModified(startTime); h.setEtag(hash); // page.getReponseHeaders().setContentLength(getLength()); MediaType type = getContentType(); if (type == null) { new NullPointerException("Null content type for " + name).printStackTrace(); } if (type != null) { h.setContentType(type); } if (internalGzip) { // Flag it so the standard compressor ignores us response.add(Headers.stringHeader("X-Internal-Compress"), "true"); } if (chunked) { response.add(Headers.stringHeader("Transfer-Encoding"), "chunked"); } if (isGzip(evt)) { page.getResponseHeaders().setContentEncoding("gzip"); if (!chunked) { response.add(Headers.CONTENT_LENGTH, (long) compressed.readableBytes()); } } else { if (!chunked) { response.add(Headers.CONTENT_LENGTH, (long) bytes.readableBytes()); } } // response.setChunked(true); response.setChunked(chunked); } @Override public void attachBytes(HttpEvent evt, Response response, boolean chunked) { if (isGzip(evt)) { CompressedBytesSender sender = new CompressedBytesSender(compressed, !evt.isKeepAlive(), chunked); response.setBodyWriter(sender); // BytesSender sender = new BytesSender(compressed); // response.setBodyWriter(sender); } else { // BytesSender sender = new BytesSender(bytes); // response.setBodyWriter(sender); CompressedBytesSender c = new CompressedBytesSender(bytes, !evt.isKeepAlive(), chunked); response.setBodyWriter(c); } } public String getEtag() { return hash; } public DateTime lastModified() { return startTime; } public MediaType getContentType() { MediaType mt = types.get(name); return mt; } public long getLength() { return length; } public Long getContentLength() { // return internalGzip ? null : (long) length; return null; } } boolean isGzip(HttpEvent evt) { if (!internalGzip) { return false; } String hdr = evt.getHeader(HttpHeaders.Names.ACCEPT_ENCODING); return hdr != null && hdr.toLowerCase().contains("gzip"); } static final class BytesSender extends ResponseWriter { private final ByteBuf bytes; public BytesSender(ByteBuf bytes) { this.bytes = Unpooled.wrappedBuffer(bytes); } @Override public Status write(Event<?> evt, Output out) throws Exception { out.write(bytes); // out.future().addListener(ChannelFutureListener.CLOSE); return Status.DONE; } } static final class CompressedBytesSender implements ChannelFutureListener { private final ByteBuf bytes; private final boolean close; private final boolean chunked; public CompressedBytesSender(ByteBuf bytes, boolean close, boolean chunked) { this.bytes = Unpooled.wrappedBuffer(bytes); this.close = close; this.chunked = chunked; } @Override public void operationComplete(ChannelFuture future) throws Exception { if (!chunked) { future = future.channel().writeAndFlush(bytes); } else { future = future.channel().write(new DefaultHttpContent(bytes)).channel().writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT); } if (close) { future.addListener(CLOSE); } } } }