1 // Copyright 2009 Google Inc. All rights reserved.
3 package com
.google
.appengine
.tools
.development
;
5 import com
.google
.apphosting
.utils
.config
.AppEngineWebXml
;
6 import com
.google
.common
.annotations
.VisibleForTesting
;
8 import org
.mortbay
.io
.WriterOutputStream
;
9 import org
.mortbay
.jetty
.HttpHeaders
;
10 import org
.mortbay
.jetty
.HttpMethods
;
11 import org
.mortbay
.resource
.Resource
;
12 import org
.mortbay
.util
.URIUtil
;
14 import java
.io
.IOException
;
15 import java
.io
.OutputStream
;
17 import java
.util
.regex
.Matcher
;
18 import java
.util
.regex
.Pattern
;
20 import javax
.servlet
.ServletException
;
21 import javax
.servlet
.RequestDispatcher
;
22 import javax
.servlet
.ServletContext
;
23 import javax
.servlet
.http
.HttpServletRequest
;
24 import javax
.servlet
.http
.HttpServletResponse
;
27 * {@code StaticFileUtils} is a collection of utilities shared by
28 * {@link LocalResourceFileServlet} and {@link StaticFileFilter}.
31 public class StaticFileUtils
{
32 private static final String DEFAULT_CACHE_CONTROL_VALUE
= "public, max-age=600";
34 private final ServletContext servletContext
;
36 public StaticFileUtils(ServletContext servletContext
) {
37 this.servletContext
= servletContext
;
40 public boolean serveWelcomeFileAsRedirect(String path
,
42 HttpServletRequest request
,
43 HttpServletResponse response
)
49 response
.setContentLength(0);
50 String q
= request
.getQueryString();
51 if (q
!= null && q
.length() != 0) {
52 response
.sendRedirect(path
+ "?" + q
);
54 response
.sendRedirect(path
);
59 public boolean serveWelcomeFileAsForward(RequestDispatcher dispatcher
,
61 HttpServletRequest request
,
62 HttpServletResponse response
)
63 throws IOException
, ServletException
{
64 if (!included
&& !request
.getRequestURI().endsWith(URIUtil
.SLASH
)) {
65 redirectToAddSlash(request
, response
);
69 request
.setAttribute("com.google.appengine.tools.development.isWelcomeFile", true);
70 if (dispatcher
!= null) {
72 dispatcher
.include(request
, response
);
74 dispatcher
.forward(request
, response
);
81 public void redirectToAddSlash(HttpServletRequest request
, HttpServletResponse response
)
83 StringBuffer buf
= request
.getRequestURL();
84 int param
= buf
.lastIndexOf(";");
88 buf
.insert(param
, '/');
90 String q
= request
.getQueryString();
91 if (q
!= null && q
.length() != 0) {
95 response
.setContentLength(0);
96 response
.sendRedirect(response
.encodeRedirectURL(buf
.toString()));
100 * Check the headers to see if content needs to be sent.
101 * @return true if the content should be sent, false otherwise.
103 public boolean passConditionalHeaders(HttpServletRequest request
,
104 HttpServletResponse response
,
105 Resource resource
) throws IOException
{
106 if (!request
.getMethod().equals(HttpMethods
.HEAD
)) {
107 String ifms
= request
.getHeader(HttpHeaders
.IF_MODIFIED_SINCE
);
111 ifmsl
= request
.getDateHeader(HttpHeaders
.IF_MODIFIED_SINCE
);
112 } catch (IllegalArgumentException e
) {
115 if (resource
.lastModified() <= ifmsl
) {
117 response
.setStatus(HttpServletResponse
.SC_NOT_MODIFIED
);
118 response
.flushBuffer();
126 date
= request
.getDateHeader(HttpHeaders
.IF_UNMODIFIED_SINCE
);
127 } catch (IllegalArgumentException e
) {
130 if (resource
.lastModified() > date
) {
131 response
.sendError(HttpServletResponse
.SC_PRECONDITION_FAILED
);
140 * Write or include the specified resource.
142 public void sendData(HttpServletRequest request
,
143 HttpServletResponse response
,
145 Resource resource
) throws IOException
{
146 long contentLength
= resource
.length();
148 writeHeaders(response
, request
.getRequestURI(), resource
, contentLength
);
151 OutputStream out
= null;
153 out
= response
.getOutputStream();}
154 catch (IllegalStateException e
) {
155 out
= new WriterOutputStream(response
.getWriter());
157 resource
.writeTo(out
, 0, contentLength
);
161 * Write the headers that should accompany the specified resource.
163 public void writeHeaders(HttpServletResponse response
, String requestPath
, Resource resource
,
166 if (count
< Integer
.MAX_VALUE
) {
167 response
.setContentLength((int) count
);
169 response
.setHeader(HttpHeaders
.CONTENT_LENGTH
, String
.valueOf(count
));
173 Set
<String
> headersApplied
= addUserStaticHeaders(requestPath
, response
);
175 if (!headersApplied
.contains("content-type")) {
176 String contentType
= servletContext
.getMimeType(resource
.getName());
177 if (contentType
!= null) {
178 response
.setContentType(contentType
);
182 if (!headersApplied
.contains("last-modified")) {
183 response
.setDateHeader(HttpHeaders
.LAST_MODIFIED
, resource
.lastModified());
186 if (!headersApplied
.contains(HttpHeaders
.CACHE_CONTROL
.toLowerCase())) {
187 response
.setHeader(HttpHeaders
.CACHE_CONTROL
, DEFAULT_CACHE_CONTROL_VALUE
);
192 * Adds HTTP Response headers that are specified in appengine-web.xml. The user may specify
193 * headers explicitly using the {@code http-header} element. Also the user may specify cache
194 * expiration headers implicitly using the {@code expiration} attribute. There is no check for
195 * consistency between different specified headers.
197 * @param localFilePath The path to the static file being served.
198 * @param response The HttpResponse object to which headers will be added
199 * @return The Set of the names of all headers that were added, canonicalized to lower case.
202 Set
<String
> addUserStaticHeaders(String localFilePath
,
203 HttpServletResponse response
) {
204 AppEngineWebXml appEngineWebXml
= (AppEngineWebXml
) servletContext
.getAttribute(
205 "com.google.appengine.tools.development.appEngineWebXml");
207 Set
<String
> headersApplied
= new HashSet
<String
>();
208 for (AppEngineWebXml
.StaticFileInclude include
: appEngineWebXml
.getStaticFileIncludes()) {
209 Pattern pattern
= include
.getRegularExpression();
210 if (pattern
.matcher(localFilePath
).matches()) {
211 for (Map
.Entry
<String
, String
> entry
: include
.getHttpHeaders().entrySet()) {
212 response
.addHeader(entry
.getKey(), entry
.getValue());
213 headersApplied
.add(entry
.getKey().toLowerCase());
215 String expirationString
= include
.getExpiration();
216 if (expirationString
!= null) {
217 addCacheControlHeaders(headersApplied
, expirationString
, response
);
222 return headersApplied
;
226 * Adds HTTP headers to the response to describe cache expiration behavior, based on the
227 * {@code expires} attribute of the {@code includes} element of the {@code static-files} element
228 * of appengine-web.xml.
230 * We follow the same logic that is used in production App Engine. This includes:
232 * <li>There is no coordination between these headers (implied by the 'expires' attribute) and
233 * explicitly specified headers (expressed with the 'http-header' sub-element). If the user
234 * specifies contradictory headers then we will include contradictory headers.
235 * <li>If the expiration time is zero then we specify that the response should not be cached using
236 * three different headers: {@code Pragma: no-cache}, {@code Expires: 0} and
237 * {@code Cache-Control: no-cache, must-revalidate}.
238 * <li>If the expiration time is positive then we specify that the response should be cached for
239 * that many seconds using two different headers: {@code Expires: num-seconds} and
240 * {@code Cache-Control: public, max-age=num-seconds}.
241 * <li>If the expiration time is not specified then we use a default value of 10 minutes
244 * Note that there is one aspect of the production App Engine logic that is not replicated here.
245 * In production App Engine if the url to a static file is protected by a security constraint in
246 * web.xml then {@code Cache-Control: private} is used instead of {@code Cache-Control: public}.
247 * In the development App Server {@code Cache-Control: public} is always used.
249 * Also if the expiration time is specified but cannot be parsed as a non-negative number of
250 * seconds then a RuntimeException is thrown.
252 * @param headersApplied Set of headers that have been applied, canonicalized to lower-case. Any
253 * new headers applied in this method will be added to the set.
254 * @param expiration The expiration String specified in appengine-web.xml
255 * @param response The HttpServletResponse into which we will write the HTTP headers.
257 private static void addCacheControlHeaders(
258 Set
<String
> headersApplied
, String expiration
, HttpServletResponse response
) {
260 int expirationSeconds
= parseExpirationSpecifier(expiration
);
261 if (expirationSeconds
== 0) {
262 response
.addHeader("Pragma", "no-cache");
263 response
.addHeader(HttpHeaders
.CACHE_CONTROL
, "no-cache, must-revalidate");
264 response
.addDateHeader(HttpHeaders
.EXPIRES
, 0);
265 headersApplied
.add(HttpHeaders
.CACHE_CONTROL
.toLowerCase());
266 headersApplied
.add(HttpHeaders
.EXPIRES
.toLowerCase());
267 headersApplied
.add("pragma");
270 if (expirationSeconds
> 0) {
271 response
.addHeader(HttpHeaders
.CACHE_CONTROL
, "public, max-age=" + expirationSeconds
);
272 response
.addDateHeader(
273 HttpHeaders
.EXPIRES
, System
.currentTimeMillis() + expirationSeconds
* 1000L);
274 headersApplied
.add(HttpHeaders
.CACHE_CONTROL
.toLowerCase());
275 headersApplied
.add(HttpHeaders
.EXPIRES
.toLowerCase());
278 throw new RuntimeException("expirationSeconds is negative: " + expirationSeconds
);
282 * Parses an expiration specifier String and returns the number of seconds it represents. A valid
283 * expiration specifier is a white-space-delimited list of components, each of which is a sequence
284 * of digits, optionally followed by a single letter from the set {D, d, H, h, M, m, S, s}. For
285 * example {@code 21D 4H 30m} represents the number of seconds in 21 days, 4.5 hours.
287 * @param expirationSpecifier The non-null, non-empty expiration specifier String to parse
288 * @return The non-negative number of seconds represented by this String.
291 static int parseExpirationSpecifier(String expirationSpecifier
) {
292 expirationSpecifier
= expirationSpecifier
.trim();
293 if (expirationSpecifier
.isEmpty()) {
294 throwExpirationParseException("", expirationSpecifier
);
296 String
[] components
= expirationSpecifier
.split("(\\s)+");
297 int expirationSeconds
= 0;
298 for (String componentSpecifier
: components
) {
300 parseExpirationSpeciferComponent(componentSpecifier
, expirationSpecifier
);
302 return expirationSeconds
;
305 private static final Pattern EXPIRATION_COMPONENT_PATTERN
= Pattern
.compile("^(\\d+)([dhms]?)$");
308 * Parses a single component of an expiration specifier, and returns the number of seconds that
309 * the component represents. A valid component specifier is a sequence of digits, optionally
310 * followed by a single letter from the set {D, d, H, h, M, m, S, s}, indicating days, hours,
311 * minutes and seconds. A lack of a trailing letter is interpreted as seconds.
313 * @param componentSpecifier The component specifier to parse
314 * @param fullSpecifier The full specifier of which {@code componentSpecifier} is a component.
315 * This will be included in an error message if necessary.
316 * @return The number of seconds represented by {@code componentSpecifier}
318 private static int parseExpirationSpeciferComponent(
319 String componentSpecifier
, String fullSpecifier
) {
320 Matcher matcher
= EXPIRATION_COMPONENT_PATTERN
.matcher(componentSpecifier
.toLowerCase());
321 if (!matcher
.matches()) {
322 throwExpirationParseException(componentSpecifier
, fullSpecifier
);
324 String numericString
= matcher
.group(1);
325 int numSeconds
= parseExpirationInteger(numericString
, componentSpecifier
, fullSpecifier
);
326 String unitString
= matcher
.group(2);
327 if (unitString
.length() > 0) {
328 switch (unitString
.charAt(0)) {
330 numSeconds
*= 24 * 60 * 60;
333 numSeconds
*= 60 * 60;
344 * Parses a String from an expiration specifier as a non-negative integer. If successful returns
345 * the integer. Otherwise throws an {@link IllegalArgumentException} indicating that the specifier
346 * could not be parsed.
348 * @param intString String to parse
349 * @param componentSpecifier The component of the specifier being parsed
350 * @param fullSpecifier The full specifier
351 * @return The parsed integer
353 private static int parseExpirationInteger(
354 String intString
, String componentSpecifier
, String fullSpecifier
) {
357 seconds
= Integer
.parseInt(intString
);
358 } catch (NumberFormatException e
) {
359 throwExpirationParseException(componentSpecifier
, fullSpecifier
);
362 throwExpirationParseException(componentSpecifier
, fullSpecifier
);
368 * Throws an {@link IllegalArgumentException} indicating that an expiration specifier String was
369 * not able to be parsed.
371 * @param componentSpecifier The component that could not be parsed
372 * @param fullSpecifier The full String
374 private static void throwExpirationParseException(
375 String componentSpecifier
, String fullSpecifier
) {
376 throw new IllegalArgumentException("Unable to parse cache expiration specifier '"
377 + fullSpecifier
+ "' at component '" + componentSpecifier
+ "'");