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
.logging
.Logger
;
18 import java
.util
.regex
.Matcher
;
19 import java
.util
.regex
.Pattern
;
21 import javax
.servlet
.ServletException
;
22 import javax
.servlet
.RequestDispatcher
;
23 import javax
.servlet
.ServletContext
;
24 import javax
.servlet
.http
.HttpServletRequest
;
25 import javax
.servlet
.http
.HttpServletResponse
;
28 * {@code StaticFileUtils} is a collection of utilities shared by
29 * {@link LocalResourceFileServlet} and {@link StaticFileFilter}.
32 public class StaticFileUtils
{
33 private static final Logger logger
= Logger
.getLogger(StaticFileUtils
.class.getName());
35 private static final String DEFAULT_CACHE_CONTROL_VALUE
= "public, max-age=600";
37 private final ServletContext servletContext
;
39 public StaticFileUtils(ServletContext servletContext
) {
40 this.servletContext
= servletContext
;
43 public boolean serveWelcomeFileAsRedirect(String path
,
45 HttpServletRequest request
,
46 HttpServletResponse response
)
52 response
.setContentLength(0);
53 String q
= request
.getQueryString();
54 if (q
!= null && q
.length() != 0) {
55 response
.sendRedirect(path
+ "?" + q
);
57 response
.sendRedirect(path
);
62 public boolean serveWelcomeFileAsForward(RequestDispatcher dispatcher
,
64 HttpServletRequest request
,
65 HttpServletResponse response
)
66 throws IOException
, ServletException
{
67 if (!included
&& !request
.getRequestURI().endsWith(URIUtil
.SLASH
)) {
68 redirectToAddSlash(request
, response
);
72 request
.setAttribute("com.google.appengine.tools.development.isWelcomeFile", true);
73 if (dispatcher
!= null) {
75 dispatcher
.include(request
, response
);
77 dispatcher
.forward(request
, response
);
84 public void redirectToAddSlash(HttpServletRequest request
, HttpServletResponse response
)
86 StringBuffer buf
= request
.getRequestURL();
87 int param
= buf
.lastIndexOf(";");
91 buf
.insert(param
, '/');
93 String q
= request
.getQueryString();
94 if (q
!= null && q
.length() != 0) {
98 response
.setContentLength(0);
99 response
.sendRedirect(response
.encodeRedirectURL(buf
.toString()));
103 * Check the headers to see if content needs to be sent.
104 * @return true if the content should be sent, false otherwise.
106 public boolean passConditionalHeaders(HttpServletRequest request
,
107 HttpServletResponse response
,
108 Resource resource
) throws IOException
{
109 if (!request
.getMethod().equals(HttpMethods
.HEAD
)) {
110 String ifms
= request
.getHeader(HttpHeaders
.IF_MODIFIED_SINCE
);
114 ifmsl
= request
.getDateHeader(HttpHeaders
.IF_MODIFIED_SINCE
);
115 } catch (IllegalArgumentException e
) {
118 if (resource
.lastModified() <= ifmsl
) {
120 response
.setStatus(HttpServletResponse
.SC_NOT_MODIFIED
);
121 response
.flushBuffer();
129 date
= request
.getDateHeader(HttpHeaders
.IF_UNMODIFIED_SINCE
);
130 } catch (IllegalArgumentException e
) {
133 if (resource
.lastModified() > date
) {
134 response
.sendError(HttpServletResponse
.SC_PRECONDITION_FAILED
);
143 * Write or include the specified resource.
145 public void sendData(HttpServletRequest request
,
146 HttpServletResponse response
,
148 Resource resource
) throws IOException
{
149 long contentLength
= resource
.length();
151 writeHeaders(response
, request
.getRequestURI(), resource
, contentLength
);
154 OutputStream out
= null;
156 out
= response
.getOutputStream();}
157 catch (IllegalStateException e
) {
158 out
= new WriterOutputStream(response
.getWriter());
160 resource
.writeTo(out
, 0, contentLength
);
164 * Write the headers that should accompany the specified resource.
166 public void writeHeaders(HttpServletResponse response
, String requestPath
, Resource resource
,
170 if (count
< Integer
.MAX_VALUE
) {
171 response
.setContentLength((int) count
);
173 response
.setHeader(HttpHeaders
.CONTENT_LENGTH
, String
.valueOf(count
));
177 Set
<String
> headersApplied
= addUserStaticHeaders(requestPath
, response
);
179 if (!headersApplied
.contains("content-type")) {
180 String contentType
= servletContext
.getMimeType(resource
.getName());
181 if (contentType
!= null) {
182 response
.setContentType(contentType
);
186 if (!headersApplied
.contains("last-modified")) {
187 response
.setDateHeader(HttpHeaders
.LAST_MODIFIED
, resource
.lastModified());
190 if (!headersApplied
.contains(HttpHeaders
.CACHE_CONTROL
.toLowerCase())) {
191 response
.setHeader(HttpHeaders
.CACHE_CONTROL
, DEFAULT_CACHE_CONTROL_VALUE
);
196 * Adds HTTP Response headers that are specified in appengine-web.xml. The user may specify
197 * headers explicitly using the {@code http-header} element. Also the user may specify cache
198 * expiration headers implicitly using the {@code expiration} attribute. There is no check for
199 * consistency between different specified headers.
201 * @param localFilePath The path to the static file being served.
202 * @param response The HttpResponse object to which headers will be added
203 * @return The Set of the names of all headers that were added, canonicalized to lower case.
206 Set
<String
> addUserStaticHeaders(String localFilePath
,
207 HttpServletResponse response
) {
208 AppEngineWebXml appEngineWebXml
= (AppEngineWebXml
) servletContext
.getAttribute(
209 "com.google.appengine.tools.development.appEngineWebXml");
211 Set
<String
> headersApplied
= new HashSet
<String
>();
212 for (AppEngineWebXml
.StaticFileInclude include
: appEngineWebXml
.getStaticFileIncludes()) {
213 Pattern pattern
= include
.getRegularExpression();
214 if (pattern
.matcher(localFilePath
).matches()) {
215 for (Map
.Entry
<String
, String
> entry
: include
.getHttpHeaders().entrySet()) {
216 response
.addHeader(entry
.getKey(), entry
.getValue());
217 headersApplied
.add(entry
.getKey().toLowerCase());
219 String expirationString
= include
.getExpiration();
220 if (expirationString
!= null) {
221 addCacheControlHeaders(headersApplied
, expirationString
, response
);
226 return headersApplied
;
230 * Adds HTTP headers to the response to describe cache expiration behavior, based on the
231 * {@code expires} attribute of the {@code includes} element of the {@code static-files} element
232 * of appengine-web.xml.
234 * We follow the same logic that is used in production App Engine. This includes:
236 * <li>There is no coordination between these headers (implied by the 'expires' attribute) and
237 * explicitly specified headers (expressed with the 'http-header' sub-element). If the user
238 * specifies contradictory headers then we will include contradictory headers.
239 * <li>If the expiration time is zero then we specify that the response should not be cached using
240 * three different headers: {@code Pragma: no-cache}, {@code Expires: 0} and
241 * {@code Cache-Control: no-cache, must-revalidate}.
242 * <li>If the expiration time is positive then we specify that the response should be cached for
243 * that many seconds using two different headers: {@code Expires: num-seconds} and
244 * {@code Cache-Control: public, max-age=num-seconds}.
245 * <li>If the expiration time is not specified then we use a default value of 10 minutes
248 * Note that there is one aspect of the production App Engine logic that is not replicated here.
249 * In production App Engine if the url to a static file is protected by a security constraint in
250 * web.xml then {@code Cache-Control: private} is used instead of {@code Cache-Control: public}.
251 * In the development App Server {@code Cache-Control: public} is always used.
253 * Also if the expiration time is specified but cannot be parsed as a non-negative number of
254 * seconds then a RuntimeException is thrown.
256 * @param headersApplied Set of headers that have been applied, canonicalized to lower-case. Any
257 * new headers applied in this method will be added to the set.
258 * @param expiration The expiration String specified in appengine-web.xml
259 * @param response The HttpServletResponse into which we will write the HTTP headers.
261 private static void addCacheControlHeaders(
262 Set
<String
> headersApplied
, String expiration
, HttpServletResponse response
) {
264 int expirationSeconds
= parseExpirationSpecifier(expiration
);
265 if (expirationSeconds
== 0) {
266 response
.addHeader("Pragma", "no-cache");
267 response
.addHeader(HttpHeaders
.CACHE_CONTROL
, "no-cache, must-revalidate");
268 response
.addDateHeader(HttpHeaders
.EXPIRES
, 0);
269 headersApplied
.add(HttpHeaders
.CACHE_CONTROL
.toLowerCase());
270 headersApplied
.add(HttpHeaders
.EXPIRES
.toLowerCase());
271 headersApplied
.add("pragma");
274 if (expirationSeconds
> 0) {
275 response
.addHeader(HttpHeaders
.CACHE_CONTROL
, "public, max-age=" + expirationSeconds
);
276 response
.addDateHeader(
277 HttpHeaders
.EXPIRES
, System
.currentTimeMillis() + expirationSeconds
* 1000L);
278 headersApplied
.add(HttpHeaders
.CACHE_CONTROL
.toLowerCase());
279 headersApplied
.add(HttpHeaders
.EXPIRES
.toLowerCase());
282 throw new RuntimeException("expirationSeconds is negative: " + expirationSeconds
);
286 * Parses an expiration specifier String and returns the number of seconds it represents. A valid
287 * expiration specifier is a white-space-delimited list of components, each of which is a sequence
288 * of digits, optionally followed by a single letter from the set {D, d, H, h, M, m, S, s}. For
289 * example {@code 21D 4H 30m} represents the number of seconds in 21 days, 4.5 hours.
291 * @param expirationSpecifier The non-null, non-empty expiration specifier String to parse
292 * @return The non-negative number of seconds represented by this String.
295 static int parseExpirationSpecifier(String expirationSpecifier
) {
296 expirationSpecifier
= expirationSpecifier
.trim();
297 if (expirationSpecifier
.isEmpty()) {
298 throwExpirationParseException("", expirationSpecifier
);
300 String
[] components
= expirationSpecifier
.split("(\\s)+");
301 int expirationSeconds
= 0;
302 for (String componentSpecifier
: components
) {
304 parseExpirationSpeciferComponent(componentSpecifier
, expirationSpecifier
);
306 return expirationSeconds
;
309 private static final Pattern EXPIRATION_COMPONENT_PATTERN
= Pattern
.compile("^(\\d+)([dhms]?)$");
312 * Parses a single component of an expiration specifier, and returns the number of seconds that
313 * the component represents. A valid component specifier is a sequence of digits, optionally
314 * followed by a single letter from the set {D, d, H, h, M, m, S, s}, indicating days, hours,
315 * minutes and seconds. A lack of a trailing letter is interpreted as seconds.
317 * @param componentSpecifier The component specifier to parse
318 * @param fullSpecifier The full specifier of which {@code componentSpecifier} is a component.
319 * This will be included in an error message if necessary.
320 * @return The number of seconds represented by {@code componentSpecifier}
322 private static int parseExpirationSpeciferComponent(
323 String componentSpecifier
, String fullSpecifier
) {
324 Matcher matcher
= EXPIRATION_COMPONENT_PATTERN
.matcher(componentSpecifier
.toLowerCase());
325 if (!matcher
.matches()) {
326 throwExpirationParseException(componentSpecifier
, fullSpecifier
);
328 String numericString
= matcher
.group(1);
329 int numSeconds
= parseExpirationInteger(numericString
, componentSpecifier
, fullSpecifier
);
330 String unitString
= matcher
.group(2);
331 if (unitString
.length() > 0) {
332 switch (unitString
.charAt(0)) {
334 numSeconds
*= 24 * 60 * 60;
337 numSeconds
*= 60 * 60;
348 * Parses a String from an expiration specifier as a non-negative integer. If successful returns
349 * the integer. Otherwise throws an {@link IllegalArgumentException} indicating that the specifier
350 * could not be parsed.
352 * @param intString String to parse
353 * @param componentSpecifier The component of the specifier being parsed
354 * @param fullSpecifier The full specifier
355 * @return The parsed integer
357 private static int parseExpirationInteger(
358 String intString
, String componentSpecifier
, String fullSpecifier
) {
361 seconds
= Integer
.parseInt(intString
);
362 } catch (NumberFormatException e
) {
363 throwExpirationParseException(componentSpecifier
, fullSpecifier
);
366 throwExpirationParseException(componentSpecifier
, fullSpecifier
);
372 * Throws an {@link IllegalArgumentException} indicating that an expiration specifier String was
373 * not able to be parsed.
375 * @param componentSpecifier The component that could not be parsed
376 * @param fullSpecifier The full String
378 private static void throwExpirationParseException(
379 String componentSpecifier
, String fullSpecifier
) {
380 throw new IllegalArgumentException("Unable to parse cache expiration specifier '"
381 + fullSpecifier
+ "' at component '" + componentSpecifier
+ "'");