Revision created by MOE tool push_codebase.
[gae.git] / java / src / main / com / google / appengine / tools / development / StaticFileUtils.java
blobc2b43722720760acc8d451abf03d754df075afcb
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;
16 import java.util.*;
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;
27 /**
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,
44 boolean included,
45 HttpServletRequest request,
46 HttpServletResponse response)
47 throws IOException {
48 if (included) {
49 return false;
52 response.setContentLength(0);
53 String q = request.getQueryString();
54 if (q != null && q.length() != 0) {
55 response.sendRedirect(path + "?" + q);
56 } else {
57 response.sendRedirect(path);
59 return true;
62 public boolean serveWelcomeFileAsForward(RequestDispatcher dispatcher,
63 boolean included,
64 HttpServletRequest request,
65 HttpServletResponse response)
66 throws IOException, ServletException {
67 if (!included && !request.getRequestURI().endsWith(URIUtil.SLASH)) {
68 redirectToAddSlash(request, response);
69 return true;
72 request.setAttribute("com.google.appengine.tools.development.isWelcomeFile", true);
73 if (dispatcher != null) {
74 if (included) {
75 dispatcher.include(request, response);
76 } else {
77 dispatcher.forward(request, response);
79 return true;
81 return false;
84 public void redirectToAddSlash(HttpServletRequest request, HttpServletResponse response)
85 throws IOException {
86 StringBuffer buf = request.getRequestURL();
87 int param = buf.lastIndexOf(";");
88 if (param < 0) {
89 buf.append('/');
90 } else {
91 buf.insert(param, '/');
93 String q = request.getQueryString();
94 if (q != null && q.length() != 0) {
95 buf.append('?');
96 buf.append(q);
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);
111 if (ifms != null) {
112 long ifmsl = -1;
113 try {
114 ifmsl = request.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE);
115 } catch (IllegalArgumentException e) {
117 if (ifmsl != -1) {
118 if (resource.lastModified() <= ifmsl) {
119 response.reset();
120 response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
121 response.flushBuffer();
122 return false;
127 long date = -1;
128 try {
129 date = request.getDateHeader(HttpHeaders.IF_UNMODIFIED_SINCE);
130 } catch (IllegalArgumentException e) {
132 if (date != -1) {
133 if (resource.lastModified() > date) {
134 response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
135 return false;
139 return true;
143 * Write or include the specified resource.
145 public void sendData(HttpServletRequest request,
146 HttpServletResponse response,
147 boolean include,
148 Resource resource) throws IOException {
149 long contentLength = resource.length();
150 if (!include) {
151 writeHeaders(response, request.getRequestURI(), resource, contentLength);
154 OutputStream out = null;
155 try {
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,
167 long count)
168 throws IOException {
169 if (count != -1) {
170 if (count < Integer.MAX_VALUE) {
171 response.setContentLength((int) count);
172 } else {
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.
205 @VisibleForTesting
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);
223 break;
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.
233 * <p>
234 * We follow the same logic that is used in production App Engine. This includes:
235 * <ul>
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
246 * </ul>
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.
252 * <p>
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");
272 return;
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());
280 return;
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.
294 @VisibleForTesting
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) {
303 expirationSeconds +=
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)) {
333 case 'd':
334 numSeconds *= 24 * 60 * 60;
335 break;
336 case 'h':
337 numSeconds *= 60 * 60;
338 break;
339 case 'm':
340 numSeconds *= 60;
341 break;
344 return numSeconds;
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) {
359 int seconds = 0;
360 try {
361 seconds = Integer.parseInt(intString);
362 } catch (NumberFormatException e) {
363 throwExpirationParseException(componentSpecifier, fullSpecifier);
365 if (seconds < 0) {
366 throwExpirationParseException(componentSpecifier, fullSpecifier);
368 return seconds;
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 + "'");