/*
* 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);
}
}
}
}