/*
* Copyright 2014 astamuse company,Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License 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.astamuse.asta4d.web.builtin;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.SoftReference;
import java.net.URLConnection;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.astamuse.asta4d.Configuration;
import com.astamuse.asta4d.Context;
import com.astamuse.asta4d.util.MemorySafeResourceCache;
import com.astamuse.asta4d.util.MemorySafeResourceCache.ResouceHolder;
import com.astamuse.asta4d.util.MultiSearchPathResourceLoader;
import com.astamuse.asta4d.util.i18n.LocalizeUtil;
import com.astamuse.asta4d.web.WebApplicationContext;
import com.astamuse.asta4d.web.dispatch.mapping.UrlMappingRule;
import com.astamuse.asta4d.web.dispatch.request.RequestHandler;
import com.astamuse.asta4d.web.dispatch.response.provider.BinaryDataProvider;
import com.astamuse.asta4d.web.dispatch.response.provider.HeaderInfoProvider;
import com.astamuse.asta4d.web.util.data.BinaryDataUtil;
/**
* A static resouce handler for service static resources such as js, css or static html files.
*
* The following path vars can be configured for specializing response headers:
*
* <ul>
* <li>{@link #VAR_CONTENT_TYPE}
* <li>{@link #VAR_CONTENT_CACHE_SIZE_LIMIT_K}
* <li>{@link #VAR_CACHE_TIME}
* <li>{@link #VAR_LAST_MODIFIED}
* </ul>
*
* Or the following methods can be override for more complex calculations:
*
* <ul>
* <li>{@link #judgContentType(String)}
* <li>{@link #getContentCacheSizeLimit(String)}
* <li>{@link #decideCacheTime(String)}
* <li>{@link #getLastModifiedTime(String)}
* </ul>
*
* @author e-ryu
*
*/
public class StaticResourceHandler extends AbstractGenericPathHandler {
/**
* see {@link #judgContentType(String)}
*/
public final static String VAR_CONTENT_TYPE = StaticResourceHandler.class.getName() + "#content_type";
/**
* see {@link #getContentCacheSizeLimit(String)}
*/
public final static String VAR_CONTENT_CACHE_SIZE_LIMIT_K = StaticResourceHandler.class.getName() + "#content_cache_size_limit_k";
/**
* see {@link #decideCacheTime(String)}
*/
public final static String VAR_CACHE_TIME = StaticResourceHandler.class.getName() + "#cache_time";
/**
* see {@link #getLastModifiedTime(String)}
*/
public final static String VAR_LAST_MODIFIED = StaticResourceHandler.class.getName() + "#last_modified";
private final static Logger logger = LoggerFactory.getLogger(StaticResourceHandler.class);
private final static long DefaultLastModified = DateTime.now().getMillis();
// one hour
private final static long DefaultCacheTime = 1000 * 60 * 60;
protected final static class StaticFileInfo {
String contentType;
String actualPath;
long lastModified;
int cacheLimit;
SoftReference<byte[]> content;
InputStream firstTimeInput;
}
private final static MemorySafeResourceCache<String, StaticFileInfo> StaticFileInfoMap = new MemorySafeResourceCache<>();
public StaticResourceHandler() {
super();
}
public StaticResourceHandler(String basePath) {
super(basePath);
}
private HeaderInfoProvider createSimpleHeaderResponse(int status) {
HeaderInfoProvider provider = new HeaderInfoProvider(status);
provider.setContinuable(false);
return provider;
}
@RequestHandler
public Object handler(HttpServletRequest request, HttpServletResponse response, ServletContext servletContext,
UrlMappingRule currentRule) throws FileNotFoundException, IOException {
String path = convertPath(currentRule);
if (path == null) {
return createSimpleHeaderResponse(404);
}
StaticFileInfo info = retrieveStaticFileInfo(servletContext, path);
return response(request, response, servletContext, info, path);
}
protected StaticFileInfo retrieveStaticFileInfo(ServletContext servletContext, String path) throws FileNotFoundException, IOException {
Locale locale = LocalizeUtil.defaultWhenNull(null);
String staticFileInfoKey = LocalizeUtil.createLocalizedKey(path, locale);
ResouceHolder<StaticFileInfo> cachedResource = Configuration.getConfiguration().isCacheEnable()
? StaticFileInfoMap.get(staticFileInfoKey) : null;
StaticFileInfo info = null;
if (cachedResource == null) {
info = createInfo(servletContext, locale, path);
StaticFileInfoMap.put(staticFileInfoKey, info);
} else {
if (cachedResource.exists()) {
info = cachedResource.get();
} else {
info = null;
}
}
return info;
}
protected Object response(HttpServletRequest request, HttpServletResponse response, ServletContext servletContext, StaticFileInfo info,
String requiredPath) throws FileNotFoundException, IOException {
if (info == null) {
return createSimpleHeaderResponse(404);
}
if (Configuration.getConfiguration().isCacheEnable()) {
long clientTime = retrieveClientCachedTime(request);
if (clientTime >= info.lastModified) {
return createSimpleHeaderResponse(304);
}
}
// our header provider is not convenient for date header... hope we can
// improve it in future
long cacheTime = decideCacheTime(requiredPath, info.actualPath);
response.setStatus(200);
response.setHeader("Content-Type", info.contentType);
response.setDateHeader("Expires", DateTime.now().getMillis() + cacheTime);
response.setDateHeader("Last-Modified", info.lastModified);
response.setHeader("Cache-control", "max-age=" + (cacheTime / 1000));
// here we do not synchronize threads because we do not matter
if (info.content == null && info.firstTimeInput != null) {
// no cache, and we have opened it at first time
InputStream input = info.firstTimeInput;
info.firstTimeInput = null;
return new BinaryDataProvider(input);
} else if (info.content == null && info.firstTimeInput == null) {
// no cache
return new BinaryDataProvider(servletContext, this.getClass().getClassLoader(), info.actualPath);
} else {
// should cache
byte[] data = null;
data = info.content.get();
if (data == null) {
InputStream input = BinaryDataUtil.retrieveInputStreamByPath(servletContext, this.getClass().getClassLoader(),
info.actualPath);
// if we went to here, which means we are not overing the cache size limit, so we do not need to check null.
data = retrieveBytesFromInputStream(input, info.cacheLimit);
info.content = new SoftReference<byte[]>(data);
}
return new BinaryDataProvider(data);
}
}
private long retrieveClientCachedTime(HttpServletRequest request) {
try {
long time = request.getDateHeader("If-Modified-Since");
return DateTimeZone.getDefault().convertLocalToUTC(time, false);
} catch (Exception e) {
logger.debug("retrieve If-Modified-Since failed", e);
return -1;
}
}
private StaticFileInfo createInfo(final ServletContext servletContext, Locale locale, String path)
throws FileNotFoundException, IOException {
MultiSearchPathResourceLoader<Pair<String, InputStream>> loader = new MultiSearchPathResourceLoader<Pair<String, InputStream>>() {
@Override
protected Pair<String, InputStream> loadResource(String name) {
InputStream is = BinaryDataUtil.retrieveInputStreamByPath(servletContext, this.getClass().getClassLoader(), name);
if (is != null) {
return Pair.of(name, is);
} else {
return null;
}
}
};
Pair<String, InputStream> foundResource = loader.searchResource("/", LocalizeUtil.getCandidatePaths(path, locale));
if (foundResource == null) {
return null;
}
StaticFileInfo info = new StaticFileInfo();
info.contentType = judgContentType(path);
info.actualPath = foundResource.getLeft();
info.lastModified = getLastModifiedTime(path);
// cut the milliseconds
info.lastModified = info.lastModified / 1000 * 1000;
info.cacheLimit = getContentCacheSizeLimit(path);
if (info.cacheLimit == 0) {// don't cache
info.content = null;
// we will use the retrieved input stream at the first time for performance reason
info.firstTimeInput = foundResource.getRight();
} else {
byte[] contentData = retrieveBytesFromInputStream(foundResource.getRight(), info.cacheLimit);
if (contentData == null) {// we cannot cache it due to over limited size
// fallback to no cache case
info.content = null;
info.firstTimeInput = null;
} else {
try {
info.content = new SoftReference<byte[]>(contentData);
} finally {
foundResource.getRight().close();
}
info.firstTimeInput = null;
}
}
return info;
}
private byte[] retrieveBytesFromInputStream(InputStream input, int cacheSize) throws IOException {
byte[] b = new byte[cacheSize];
int len = input.read(b);
if (input.read() >= 0) {// over the limit of cache size
return null;
} else {
if (len < cacheSize) {
byte[] nb = new byte[len];
System.arraycopy(b, 0, nb, 0, len);
return nb;
} else {// going to buy lottery
return b;
}
}
}
private final static Map<String, String> MimeTypeMap = new HashMap<>();
static {
MimeTypeMap.put("js", "application/javascript");
MimeTypeMap.put("css", "text/css");
MimeTypeMap.put("ico", "image/x-icon");
}
/**
* The header value of Content-Type
*
* @param path
* @return a guess of the content type by file name extension, "application/octet-stream" when not matched
*/
protected String judgContentType(String path) {
Context context = Context.getCurrentThreadContext();
String forceContentType = context.getData(WebApplicationContext.SCOPE_PATHVAR, VAR_CONTENT_TYPE);
if (forceContentType != null) {
return forceContentType;
}
String fileName = FilenameUtils.getName(path);
// guess the type by file name extension
String type = URLConnection.guessContentTypeFromName(fileName);
if (type == null) {
type = MimeTypeMap.get(FilenameUtils.getExtension(fileName));
}
if (type == null) {
type = "application/octet-stream";
}
return type;
}
/**
* The header value of Cache-control and Expires.
*
* override this method to supply the specialized cache time.
*
* @param path
* @return cache time in millisecond unit
*/
protected long decideCacheTime(String requiredPath, String actualTargetFilePath) {
Long varCacheTime = Context.getCurrentThreadContext().getData(WebApplicationContext.SCOPE_PATHVAR, VAR_CACHE_TIME);
if (varCacheTime != null) {
return varCacheTime;
} else {
return DefaultCacheTime;
}
}
/**
*
* The header value of Last-Modified.
*
* override this method to supply the specialized last modified time
*
* @param path
* @return the time of last modified time in millisecond unit(In http protocol, the time unit should be second, but we will cope with
* this matter)
*/
protected long getLastModifiedTime(String path) {
WebApplicationContext context = Context.getCurrentThreadContext();
Long varLastModified = context.getData(WebApplicationContext.SCOPE_PATHVAR, VAR_LAST_MODIFIED);
if (varLastModified != null) {
return varLastModified;
} else {
long retrieveTime = BinaryDataUtil.retrieveLastModifiedByPath(context.getServletContext(), this.getClass().getClassLoader(),
path);
if (retrieveTime == 0L) {
return DefaultLastModified;
} else {
return retrieveTime;
}
}
}
/**
* Retrieve the max cachable size limit for a certain path in byte unit.Be care of that the path var is set by kilobyte unit for
* convenience but this method will return in byte unit. <br>
* This is a default implementation which does not see the path and will return 0 for not caching when path var is not set.
* <p>
* Note: we do not cache it by default because the resources in war should have been cached by the servlet container.
*
*
* @param path
* @return the max cachable size limit for a certain path in byte unit.
*/
protected int getContentCacheSizeLimit(String path) {
Integer varCacheSize = Context.getCurrentThreadContext().getData(WebApplicationContext.SCOPE_PATHVAR,
VAR_CONTENT_CACHE_SIZE_LIMIT_K);
if (varCacheSize != null) {
return varCacheSize * 1000;
} else {
return 0;
}
}
}