// ================================================================================================= // Copyright 2011 Twitter, Inc. // ------------------------------------------------------------------------------------------------- // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this work except in compliance with the License. // You may obtain a copy of the License in the LICENSE file, or 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. // ================================================================================================= package com.twitter.common.net.http.handlers; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Splitter; import com.google.common.collect.Iterables; import com.google.common.io.ByteStreams; import com.google.common.io.Closeables; import com.google.common.io.InputSupplier; import com.twitter.common.quantity.Amount; import com.twitter.common.quantity.Time; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; import org.apache.commons.codec.binary.Base64; import org.apache.commons.codec.digest.DigestUtils; import static com.google.common.base.Preconditions.checkNotNull; /** * Servlet that is responsible for serving an asset. * * @author William Farner */ public class AssetHandler extends HttpServlet { @VisibleForTesting static final Amount<Integer, Time> CACHE_CONTROL_MAX_AGE_SECS = Amount.of(30, Time.DAYS); private static final String GZIP_ENCODING = "gzip"; private final StaticAsset staticAsset; public static class StaticAsset { private final InputSupplier<? extends InputStream> inputSupplier; private final String contentType; private final boolean cacheLocally; private byte[] gzipData = null; private String hash = null; /** * Creates a new static asset. * * @param inputSupplier Supplier of the input stream from which to load the asset. * @param contentType HTTP content type of the asset. * @param cacheLocally If {@code true} the asset will be loaded once and stored in memory, if * {@code false} it will be loaded on each request. */ public StaticAsset(InputSupplier<? extends InputStream> inputSupplier, String contentType, boolean cacheLocally) { this.inputSupplier = checkNotNull(inputSupplier); this.contentType = checkNotNull(contentType); this.cacheLocally = cacheLocally; } public String getContentType() { return contentType; } public synchronized byte[] getRawData() throws IOException { byte[] zipData = getGzipData(); GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(zipData)); return ByteStreams.toByteArray(in); } public synchronized byte[] getGzipData() throws IOException { byte[] data = gzipData; // Ensure we don't double-read after a call to getChecksum(). if (!cacheLocally || gzipData == null) { load(); data = gzipData; } if (!cacheLocally) { gzipData = null; } return data; } public synchronized String getChecksum() throws IOException { if (hash == null) { load(); } return hash; } private void load() throws IOException { ByteArrayOutputStream gzipBaos = new ByteArrayOutputStream(); GZIPOutputStream gzipStream = new GZIPOutputStream(gzipBaos); ByteStreams.copy(inputSupplier, gzipStream); gzipStream.flush(); // copy() does not flush or close output stream. gzipStream.close(); gzipData = gzipBaos.toByteArray(); // Calculate a checksum of the gzipped data. hash = Base64.encodeBase64String(DigestUtils.md5(gzipData)).trim(); } } /** * Creates a new asset handler. * * @param staticAsset The asset to serve. */ public AssetHandler(StaticAsset staticAsset) { this.staticAsset = checkNotNull(staticAsset); } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { OutputStream responseBody = resp.getOutputStream(); if (checksumMatches(req)) { resp.setStatus(HttpServletResponse.SC_NOT_MODIFIED); } else { setPayloadHeaders(resp); boolean gzip = supportsGzip(req); if (gzip) { resp.setHeader("Content-Encoding", GZIP_ENCODING); } InputStream in = new ByteArrayInputStream( gzip ? staticAsset.getGzipData() : staticAsset.getRawData()); ByteStreams.copy(in, responseBody); } Closeables.closeQuietly(responseBody); } private void setPayloadHeaders(HttpServletResponse resp) throws IOException { resp.setStatus(HttpServletResponse.SC_OK); resp.setContentType(staticAsset.getContentType()); resp.setHeader("Cache-Control", "public,max-age=" + CACHE_CONTROL_MAX_AGE_SECS); String checksum = staticAsset.getChecksum(); if (checksum != null) { resp.setHeader("ETag", checksum); } } private boolean checksumMatches(HttpServletRequest req) throws IOException { // TODO(William Farner): Change this to more fully comply with // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.26 // Specifically - a response to 'If-None-Match: *' should include ETag as well as other // cache-related headers. String suppliedETag = req.getHeader("If-None-Match"); if ("*".equals(suppliedETag)) { return true; } String checksum = staticAsset.getChecksum(); // Note - this isn't a completely accurate check since the tag we end up matching against could // theoretically be the actual tag with some extra characters appended. return (checksum != null) && (suppliedETag != null) && suppliedETag.contains(checksum); } private static boolean supportsGzip(HttpServletRequest req) { String header = req.getHeader("Accept-Encoding"); return (header != null) && Iterables.contains(Splitter.on(",").trimResults().split(header), GZIP_ENCODING); } }