package org.jboss.seam.web;
import org.jboss.seam.log.LogProvider;
import org.jboss.seam.log.Logging;
import org.jboss.seam.util.Resources;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.net.URLConnection;
import java.net.URL;
import java.lang.management.ManagementFactory;
/**
* Subclass this resource if you want to be able to send the right response automatically to
* any conditional <tt>GET</tt> or <tt>HEAD</tt> request. The typically usecase is as follows:
* <p/>
* <pre>
* public class MyResource extends ConditionalAbstractResource {
*
* public void getResource(final HttpServletRequest request, final HttpServletResponse response) {
* String resourceVersion = ... // Calculate current state as string
* or
* byte[] resourceVersion = ... // Calculate current state as bytes
*
* String resourcePath = ... // Get the relative (to servlet) path of the requested resource
*
* if ( !sendConditional(request,
* response,
* createdEntityTag(resourceVersion, false),
* getLastModifiedTimestamp(resourcePath) ) {
*
* // Send the regular resource representation with 200 OK etc.
* }
* }
* }
* </pre>
* <p/>
* Note that the <tt>getLastModifiedTimestamp()</tt> method is only supplied for convenience; it may not
* return what you expect as the "last modification timestamp" of the given resource. In many cases you'd
* rather calculate that timestamp yourself.
* <p/>
*
* @author Christian Bauer
*/
public abstract class ConditionalAbstractResource extends AbstractResource
{
public static final String HEADER_LAST_MODIFIED = "Last-Modified";
public static final String HEADER_IF_MODIFIED_SINCE = "If-Modified-Since";
public static final String HEADER_ETAG = "ETag";
public static final String HEADER_IF_NONE_MATCH = "If-None-Match";
private static final LogProvider log = Logging.getLogProvider(ConditionalAbstractResource.class);
/**
* Validates the request headers <tt>If-Modified-Since</tt> and <tt>If-None-Match</tt> to determine
* if a <tt>304 NOT MODIFIED</tt> response can be send. If that is the case, this method will automatically
* send the response and return <tt>true</tt>. If condition validation fails, it will not change the
* response and return <tt>false</tt>.
* <p/>
* Note that both <tt>entityTag</tt> and <tt>lastModified</tt> arguments can be <tt>null</tt>. The validation
* procedure and the outcome depends on what the client requested. If the client requires that both entity tags and
* modification timestamps be validated, both arguments must be supplied to the method and they must match, for
* a 304 response to be send.
* <p/>
* In addition to responding with <tt>304 NOT MODIFIED</tt> when conditions match, this method will also, if
* arguments are not <tt>null</tt>, send the right entity tag and last modification timestamps with the response,
* so that future requests from the client can be made conditional.
* <p/>
*
* @param request The usual HttpServletRequest for header retrieval.
* @param response The usual HttpServletResponse for header manipulation.
* @param entityTag An entity tag (weak or strong, in doublequotes), typically produced by hashing the content
* of the resource representation. If <tt>null</tt>, no entity tag will be send and if
* validation is requested by the client, no match for a NOT MODIFIED response will be possible.
* @param lastModified The timestamp in number of milliseconds since unix epoch when the resource was
* last modified. If <tt>null</tt>, no last modification timestamp will be send and if
* validation is requested by the client, no match for a NOT MODIFIED response will be possible.
* @return <tt>true</tt> if a <tt>304 NOT MODIFIED</tt> response status has been set, <tt>false</tt> if requested
* conditions were invalid given the current state of the resource.
* @throws IOException If setting the response status failed.
*/
public boolean sendConditional(HttpServletRequest request,
HttpServletResponse response,
String entityTag, Long lastModified) throws IOException
{
String noneMatchHeader = request.getHeader(HEADER_IF_NONE_MATCH);
Long modifiedSinceHeader = request.getDateHeader(HEADER_IF_MODIFIED_SINCE); // Careful, returns -1 instead of null!
boolean noneMatchValid = false;
if (entityTag != null)
{
if (! (entityTag.startsWith("\"") || entityTag.startsWith("W/\"")) && !entityTag.endsWith("\""))
{
throw new IllegalArgumentException("Entity tag is not properly formatted (or quoted): " + entityTag);
}
// Always send an entity tag with the response
response.setHeader(HEADER_ETAG, entityTag);
if (noneMatchHeader != null)
{
noneMatchValid = isNoneMatchConditionValid(noneMatchHeader, entityTag);
}
}
boolean modifiedSinceValid = false;
if (lastModified != null)
{
// Always send the last modified timestamp with the response
response.setDateHeader(HEADER_LAST_MODIFIED, lastModified);
if (modifiedSinceHeader != -1)
{
modifiedSinceValid = isModifiedSinceConditionValid(modifiedSinceHeader, lastModified);
}
}
if (noneMatchHeader != null && modifiedSinceHeader != -1)
{
log.debug(HEADER_IF_NONE_MATCH + " and " + HEADER_IF_MODIFIED_SINCE + " must match");
// If both are received, we must not return 304 unless doing so is consistent with both header fields in the request!
if (noneMatchValid && modifiedSinceValid)
{
log.debug(HEADER_IF_NONE_MATCH + " and " + HEADER_IF_MODIFIED_SINCE + " conditions match, sending 304");
response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
return true;
}
else
{
log.debug(HEADER_IF_NONE_MATCH + " and " + HEADER_IF_MODIFIED_SINCE + " conditions do not match, not sending 304");
return false;
}
}
if (noneMatchHeader != null && noneMatchValid)
{
log.debug(HEADER_IF_NONE_MATCH + " condition matches, sending 304");
response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
return true;
}
if (modifiedSinceHeader != -1 && modifiedSinceValid)
{
log.debug(HEADER_IF_MODIFIED_SINCE + " condition matches, sending 304");
response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
return true;
}
log.debug("None of the cache conditions match, not sending 304");
return false;
}
protected boolean isNoneMatchConditionValid(String noneMatchHeader, String entityTag)
{
if (noneMatchHeader.trim().equals("*"))
{
log.debug("Found * conditional request, hence current entity tag matches");
return true;
}
String[] entityTagsArray = noneMatchHeader.trim().split(",");
for (String requestTag : entityTagsArray)
{
if (requestTag.trim().equals(entityTag))
{
log.debug("Found matching entity tag in request");
return true;
}
}
log.debug("Resource has different entity tag than requested");
return false;
}
protected boolean isModifiedSinceConditionValid(Long modifiedSinceHeader, Long lastModified)
{
if (lastModified <= modifiedSinceHeader)
{
log.debug("Resource has not been modified since requested timestamp");
return true;
}
log.debug("Resource has been modified since requested timestamp");
return false;
}
/**
* Tries to get last modification timestamp of the resource by obtaining
* a <tt>URLConnection</tt> to the file in the filesystem or JAR.
*
* @param resourcePath The relative (to the servlet) resource path.
* @return Either the last modified filestamp or if an error occurs, the JVM system startup timestamp.
*/
protected Long getLastModifiedTimestamp(String resourcePath)
{
try
{
// Try to load it from filesystem or JAR through URLConnection
URL resourceURL = Resources.getResource(resourcePath, getServletContext());
if (resourceURL == null)
{
// Fall back to startup time of the JVM
return ManagementFactory.getRuntimeMXBean().getStartTime();
}
URLConnection resourceConn = resourceURL.openConnection();
return resourceConn.getLastModified();
}
catch (Exception ex)
{
// Fall back to startup time of the JVM
return ManagementFactory.getRuntimeMXBean().getStartTime();
}
}
/**
* Generates a (globally) unique identifier of the current state of the resource. The string will be
* hashed with MD5 and the hash result is then formatted before it is returned. If <tt>null</tt>,
* a <tt>null</tt> will be returned.
*
* @param hashSource The string source for hashing or the already hashed (strong or weak) entity tag.
* @param weak Set to <tt>true</tt> if you want a weak entity tag.
* @return The hashed and formatted entity tag result.
*/
protected String createEntityTag(String hashSource, boolean weak)
{
if (hashSource == null) return null;
return (weak ? "W/\"" : "\"") + hash(hashSource, "UTF-8", "MD5") + "\"";
}
/**
* Generates a (globally) unique identifier of the current state of the resource. The bytes will be
* hashed with MD5 and the hash result is then formatted before it is returned. If <tt>null</tt>,
* a <tt>null</tt> will be returned.
*
* @param hashSource The string source for hashing.
* @param weak Set to <tt>true</tt> if you want a weak entity tag.
* @return The hashed and formatted entity tag result.
*/
protected String createEntityTag(byte[] hashSource, boolean weak)
{
if (hashSource == null) return null;
return (weak ? "W/\"" : "\"") + hash(hashSource, "MD5") + "\"";
}
protected String hash(String text, String charset, String algorithm)
{
try
{
return hash(text.getBytes(charset), algorithm);
}
catch (Exception e)
{
throw new RuntimeException(e);
}
}
protected String hash(byte[] bytes, String algorithm)
{
try
{
MessageDigest md = MessageDigest.getInstance(algorithm);
md.update(bytes);
BigInteger number = new BigInteger(1, md.digest());
StringBuffer sb = new StringBuffer("0");
sb.append(number.toString(16));
return sb.toString();
}
catch (Exception e)
{
throw new RuntimeException(e);
}
}
}