/* * 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 com.mastfrog.giulius.DeploymentMode; import com.mastfrog.settings.Settings; import com.mastfrog.url.Path; 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.BufferedInputStream; import java.io.File; import java.io.FileInputStream; 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; import org.openide.util.Exceptions; /** * Resources based on java.io.File. Note that this implementation caches * all file bytes <b>in memory</b>. In practice, sites are usually small and * this is a non-issue, and this performs very well (it will notice if the timestamp * on a file has changed and reload it). * * @author Tim Boudreau */ @Singleton public final class FileResources implements StaticResources { private final MimeTypes types; private final Map<String, Resource> names = new HashMap<>(); private final String[] patterns; private final DeploymentMode mode; private final ByteBufAllocator allocator; private final boolean internalGzip; private final File dir; private final boolean debug; public static final String RESOURCES_BASE_PATH = "resources.base.path"; @Inject public FileResources(File dir, MimeTypes types, DeploymentMode mode, ByteBufAllocator allocator, Settings settings, ExpiresPolicy policy) throws Exception { Checks.notNull("allocator", allocator); Checks.notNull("types", types); Checks.notNull("dir", dir); Checks.notNull("mode", mode); this.dir = dir; this.allocator = allocator; internalGzip = settings.getBoolean("internal.gzip", false); this.types = types; this.mode = mode; List<String> l = new ArrayList<>(); scan(dir, "", l); patterns = l.toArray(new String[0]); debug = settings.getBoolean("acteur.debug", false); String resourcesBasePath = settings.getString(RESOURCES_BASE_PATH, ""); for (String name : l) { String pth = Strings.join(resourcesBasePath,name); if (debug) { System.out.println("STATIC RES: " + name + " -> " + pth); } Path p = Path.parse(pth); DateTime expires = policy.get(types.get(pth), p); Duration maxAge = expires == null ? Duration.standardHours(2) : new Duration(DateTime.now(), expires); this.names.put(pth, new FileResource2(name, maxAge)); } } private void scan(File dir, String path, List<String> result) { for (File f : dir.listFiles()) { if (f.isFile() && f.canRead()) { result.add(path + (path.isEmpty() ? "" : "/") + f.getName()); } else if (f.isDirectory()) { scan(f, path + (path.isEmpty() ? "" : "/") + f.getName(), result); } } } 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 FileResource2 implements Resource, ContentLengthProvider { private ByteBuf bytes; private ByteBuf compressed; private String hash; private final String name; private int length; private final File file; private long lastModified; private final Duration maxAge; FileResource2(String name, Duration maxAge) throws Exception { Checks.notNull("name", name); this.name = name; file = new File(dir, name); load(); this.maxAge = maxAge; } private synchronized void load() throws Exception { ByteBuf bytes = allocator.directBuffer((int) file.length()); try (InputStream in = new BufferedInputStream(new FileInputStream(file))) { 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(); } } } lastModified = file.lastModified(); 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(); bytes.resetReaderIndex(); compressed.resetReaderIndex(); } else { compressed = null; } bytes.resetReaderIndex(); this.bytes = bytes; 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) { // XXX would be nicer not to hit the filesystem every time here if (file.lastModified() != lastModified) { try { load(); } catch (Exception ex) { Exceptions.printStackTrace(ex); } } 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, maxAge); page.getResponseHeaders().addCacheControl(CacheControlTypes.must_revalidate); // } else { // page.getReponseHeaders().addCacheControl(CacheControlTypes.Private); // page.getReponseHeaders().addCacheControl(CacheControlTypes.no_cache); // page.getReponseHeaders().addCacheControl(CacheControlTypes.no_store); // } //// if (evt.getMethod() != Method.HEAD) { // page.getReponseHeaders().setContentLengthProvider(this); // } h.setLastModified(new DateTime(lastModified)); h.setEtag(hash); // page.getReponseHeaders().setContentLength(getLength()); MediaType type = getContentType(); if (type == null && debug) { System.err.println("Null content type for " + name); } 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"); response.add(Headers.CONTENT_ENCODING, "gzip"); if (!chunked) { response.add(Headers.CONTENT_LENGTH, (long) compressed.readableBytes()); } } else { if (!chunked) { response.add(Headers.CONTENT_LENGTH, (long) bytes.readableBytes()); } } response.setChunked(chunked); } @Override public void attachBytes(HttpEvent evt, Response response, boolean chunked) { if (isGzip(evt)) { CompressedBytesSender sender = new CompressedBytesSender(compressed.copy(), !evt.isKeepAlive(), chunked); response.setBodyWriter(sender); } else { CompressedBytesSender c = new CompressedBytesSender(bytes.copy(), !evt.isKeepAlive(), chunked); response.setBodyWriter(c); } } public String getEtag() { return hash; } public DateTime lastModified() { return new DateTime(lastModified); } 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 = bytes.duplicate(); } @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 = bytes.duplicate(); 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); } } } }