blob: 57bc735887a94e1e61291a370732b2d9a56f3cd6 [file] [log] [blame]
// Copyright 2004, 2005 The Apache Software Foundation
//
// 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 org.apache.tapestry.asset;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.logging.Log;
import org.apache.hivemind.ClassResolver;
import org.apache.hivemind.util.Defense;
import org.apache.tapestry.IRequestCycle;
import org.apache.tapestry.Tapestry;
import org.apache.tapestry.engine.IEngineService;
import org.apache.tapestry.engine.ILink;
import org.apache.tapestry.error.RequestExceptionReporter;
import org.apache.tapestry.services.LinkFactory;
import org.apache.tapestry.services.ServiceConstants;
import org.apache.tapestry.util.ContentType;
import org.apache.tapestry.util.io.GzipUtil;
import org.apache.tapestry.web.WebContext;
import org.apache.tapestry.web.WebRequest;
import org.apache.tapestry.web.WebResponse;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URL;
import java.net.URLConnection;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
import java.util.zip.GZIPOutputStream;
/**
* A service for building URLs to and accessing {@link org.apache.tapestry.IAsset}s. Most of the
* work is deferred to the {@link org.apache.tapestry.IAsset}instance.
* <p>
* The retrieval part is directly linked to {@link PrivateAsset}. The service responds to a URL
* that encodes the path of a resource within the classpath. The {@link #service(IRequestCycle)}
* method reads the resource and streams it out.
* <p>
* TBD: Security issues. Should only be able to retrieve a resource that was previously registerred
* in some way ... otherwise, hackers will be able to suck out the .class files of the application!
*
* @author Howard Lewis Ship
*/
public class AssetService implements IEngineService
{
/**
* Query parameter that stores the path to the resource (with a leading slash).
*
* @since 4.0
*/
public static final String PATH = "path";
/**
* Query parameter that stores the digest for the file; this is used to authenticate that the
* client is allowed to access the file.
*
* @since 4.0
*/
public static final String DIGEST = "digest";
/**
* Defaults MIME types, by extension, used when the servlet container doesn't provide MIME
* types. ServletExec Debugger, for example, fails to provide these.
*/
private static final Map _mimeTypes;
static
{
_mimeTypes = new HashMap(17);
_mimeTypes.put("css", "text/css");
_mimeTypes.put("gif", "image/gif");
_mimeTypes.put("jpg", "image/jpeg");
_mimeTypes.put("jpeg", "image/jpeg");
_mimeTypes.put("png", "image/png");
_mimeTypes.put("htm", "text/html");
_mimeTypes.put("html", "text/html");
}
/** Represents a month of time in seconds. */
static final long MONTH_SECONDS = 60 * 60 * 24 * 30;
private Log _log;
/** @since 4.0 */
private ClassResolver _classResolver;
/** @since 4.0 */
private LinkFactory _linkFactory;
/** @since 4.0 */
private WebContext _context;
/** @since 4.0 */
private WebRequest _request;
/** @since 4.0 */
private WebResponse _response;
/** @since 4.0 */
private ResourceDigestSource _digestSource;
/** @since 4.1 */
private ResourceMatcher _unprotectedMatcher;
/**
* Startup time for this service; used to set the Last-Modified response header.
*
* @since 4.0
*/
private final long _startupTime = System.currentTimeMillis();
/**
* Time vended assets expire. Since a change in asset content is a change in asset URI, we want
* them to not expire ... but a year will do.
*/
final long _expireTime = _startupTime + 365 * 24 * 60 * 60 * 1000L;
/** @since 4.0 */
private RequestExceptionReporter _exceptionReporter;
/**
* Cache of static content resources.
*/
private final Map _cache = new HashMap();
/**
* Builds a {@link ILink}for a {@link PrivateAsset}.
* <p>
* A single parameter is expected, the resource path of the asset (which is expected to start
* with a leading slash).
*/
public ILink getLink(boolean post, Object parameter)
{
Defense.isAssignable(parameter, String.class, "parameter");
String path = (String) parameter;
String digest = null;
if(!_unprotectedMatcher.containsResource(path))
digest = _digestSource.getDigestForResource(path);
Map parameters = new TreeMap(new AssetComparator());
parameters.put(ServiceConstants.SERVICE, getName());
parameters.put(PATH, path);
if (digest != null)
parameters.put(DIGEST, digest);
// Service is stateless, which is the exception to the rule.
return _linkFactory.constructLink(this, post, parameters, false);
}
public String getName()
{
return Tapestry.ASSET_SERVICE;
}
private String getMimeType(String path)
{
String result = _context.getMimeType(path);
if (result == null)
{
int dotx = path.lastIndexOf('.');
if (dotx > -1)
{
String key = path.substring(dotx + 1).toLowerCase();
result = (String) _mimeTypes.get(key);
}
if (result == null)
result = "text/plain";
}
return result;
}
/**
* Retrieves a resource from the classpath and returns it to the client in a binary output
* stream.
*/
public void service(IRequestCycle cycle)
throws IOException
{
String path = cycle.getParameter(PATH);
String md5Digest = cycle.getParameter(DIGEST);
boolean checkDigest = !_unprotectedMatcher.containsResource(path);
URLConnection resourceConnection;
try
{
if (checkDigest && !_digestSource.getDigestForResource(path).equals(md5Digest))
{
_response.sendError(HttpServletResponse.SC_FORBIDDEN, AssetMessages.md5Mismatch(path));
return;
}
// If they were vended an asset in the past then it must be up-to date.
// Asset URIs change if the underlying file is modified. (unless unprotected)
if (checkDigest && _request.getHeader("If-Modified-Since") != null)
{
_response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
return;
}
URL resourceURL = _classResolver.getResource(translatePath(path));
if (resourceURL == null)
{
_response.setStatus(HttpServletResponse.SC_NOT_FOUND);
_log.warn(AssetMessages.noSuchResource(path));
return;
}
resourceConnection = resourceURL.openConnection();
// check caching for unprotected resources
if (!checkDigest && cachedResource(resourceConnection))
return;
writeAssetContent(cycle, path, resourceConnection);
}
catch (IOException eof)
{
// ignored / expected exceptions happen when browser prematurely abandons connections - IE does this a lot
}
catch (Throwable ex)
{
_exceptionReporter.reportRequestException(AssetMessages.exceptionReportTitle(path), ex);
}
}
/**
* Utility that helps to resolve css file relative resources included
* in a css temlpate via "url('../images/foo.gif')" or fix paths containing
* relative resource ".." style notation.
*
* @param path The incoming path to check for relativity.
* @return The path unchanged if not containing a css relative path, otherwise
* returns the path without the css filename in it so the resource is resolvable
* directly from the path.
*/
String translatePath(String path)
{
if (path == null)
return null;
String ret = FilenameUtils.normalize(path);
ret = FilenameUtils.separatorsToUnix(ret);
return ret;
}
/**
* Checks if the resource contained within the specified URL
* has a modified time greater than the request header value
* of <code>If-Modified-Since</code>. If it doesn't then the
* response status is set to {@link HttpServletResponse#SC_NOT_MODIFIED}.
*
* @param resourceURL Resource being checked
* @return True if resource should be cached and response header was set.
* @since 4.1
*/
boolean cachedResource(URLConnection resourceURL)
{
// even if it doesn't exist in header the value will be -1,
// which means we need to write out the contents of the resource
long modifiedSince = _request.getDateHeader("If-Modified-Since");
if (modifiedSince <= 0)
return false;
if (_log.isDebugEnabled())
_log.debug("cachedResource(" + resourceURL.getURL() + ") modified-since header is: " + modifiedSince);
if (resourceURL.getLastModified() > modifiedSince)
return false;
_response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
return true;
}
/**
* Writes the asset specified by <code>resourceConnection</code> out to the response stream.
*
* @param cycle
* The current request.
* @param resourcePath
* The path of the resource.
* @param resourceConnection
* A connection for the resource.
* @throws IOException On error.
*/
private void writeAssetContent(IRequestCycle cycle, String resourcePath, URLConnection resourceConnection)
throws IOException
{
// Getting the content type and length is very dependant
// on support from the application server (represented
// here by the servletContext).
String contentType = getMimeType(resourcePath);
long lastModified = resourceConnection.getLastModified();
if (lastModified <= 0)
lastModified = _startupTime;
_response.setDateHeader("Last-Modified", lastModified);
// write out expiration/cache info
_response.setDateHeader("Expires", _expireTime);
_response.setHeader("Cache-Control", "public, max-age=" + (MONTH_SECONDS * 3));
// Set the content type. If the servlet container doesn't
// provide it, try and guess it by the extension.
if (contentType == null || contentType.length() == 0)
contentType = getMimeType(resourcePath);
byte[] data = getAssetData(cycle, resourcePath, resourceConnection, contentType);
// See ETag definition - http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.19
_response.setHeader("ETag", "W/\"" + data.length + "-" + lastModified + "\"");
// force image(or other) caching when detected, esp helps with ie related things
// see http://mir.aculo.us/2005/08/28/internet-explorer-and-ajax-image-caching-woes
_response.setContentLength(data.length);
OutputStream output = _response.getOutputStream(new ContentType(contentType));
output.write(data);
}
byte[] getAssetData(IRequestCycle cycle, String resourcePath,
URLConnection resourceConnection, String contentType)
throws IOException
{
InputStream input = null;
try {
CachedAsset cache;
byte[] data = null;
// check cache first
if (_cache.get(resourcePath) != null)
{
cache = (CachedAsset)_cache.get(resourcePath);
if (cache.getLastModified() < resourceConnection.getLastModified())
cache.clear(resourceConnection.getLastModified());
data = cache.getData();
} else
{
cache = new CachedAsset(resourcePath, resourceConnection.getLastModified(), null, null);
_cache.put(resourcePath, cache);
}
if (data == null)
{
input = resourceConnection.getInputStream();
data = IOUtils.toByteArray(input);
cache.setData(data);
}
// compress javascript responses when possible
if (GzipUtil.shouldCompressContentType(contentType) && GzipUtil.isGzipCapable(_request))
{
if (cache.getGzipData() == null)
{
ByteArrayOutputStream bo = new ByteArrayOutputStream();
GZIPOutputStream gzip = new GZIPOutputStream(bo);
gzip.write(data);
gzip.close();
data = bo.toByteArray();
cache.setGzipData(data);
} else
data = cache.getGzipData();
_response.setHeader("Content-Encoding", "gzip");
}
return data;
} finally {
if (input != null) {
IOUtils.closeQuietly(input);
}
}
}
/** @since 4.0 */
public void setExceptionReporter(RequestExceptionReporter exceptionReporter)
{
_exceptionReporter = exceptionReporter;
}
/** @since 4.0 */
public void setLinkFactory(LinkFactory linkFactory)
{
_linkFactory = linkFactory;
}
/** @since 4.0 */
public void setClassResolver(ClassResolver classResolver)
{
_classResolver = classResolver;
}
/** @since 4.0 */
public void setContext(WebContext context)
{
_context = context;
}
/** @since 4.0 */
public void setResponse(WebResponse response)
{
_response = response;
}
/** @since 4.0 */
public void setDigestSource(ResourceDigestSource md5Source)
{
_digestSource = md5Source;
}
/** @since 4.0 */
public void setRequest(WebRequest request)
{
_request = request;
}
/** @since 4.1 */
public void setUnprotectedMatcher(ResourceMatcher matcher)
{
_unprotectedMatcher = matcher;
}
public void setLog(Log log)
{
_log = log;
}
}