Revision created by MOE tool push_codebase.
[gae.git] / java / src / main / com / google / appengine / tools / development / StaticFileUtils.java
bloba594b78078f3c376defb493bba55bb46403a2283
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.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;
26 /**
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,
41 boolean included,
42 HttpServletRequest request,
43 HttpServletResponse response)
44 throws IOException {
45 if (included) {
46 return false;
49 response.setContentLength(0);
50 String q = request.getQueryString();
51 if (q != null && q.length() != 0) {
52 response.sendRedirect(path + "?" + q);
53 } else {
54 response.sendRedirect(path);
56 return true;
59 public boolean serveWelcomeFileAsForward(RequestDispatcher dispatcher,
60 boolean included,
61 HttpServletRequest request,
62 HttpServletResponse response)
63 throws IOException, ServletException {
64 if (!included && !request.getRequestURI().endsWith(URIUtil.SLASH)) {
65 redirectToAddSlash(request, response);
66 return true;
69 request.setAttribute("com.google.appengine.tools.development.isWelcomeFile", true);
70 if (dispatcher != null) {
71 if (included) {
72 dispatcher.include(request, response);
73 } else {
74 dispatcher.forward(request, response);
76 return true;
78 return false;
81 public void redirectToAddSlash(HttpServletRequest request, HttpServletResponse response)
82 throws IOException {
83 StringBuffer buf = request.getRequestURL();
84 int param = buf.lastIndexOf(";");
85 if (param < 0) {
86 buf.append('/');
87 } else {
88 buf.insert(param, '/');
90 String q = request.getQueryString();
91 if (q != null && q.length() != 0) {
92 buf.append('?');
93 buf.append(q);
95 response.setContentLength(0);
96 response.sendRedirect(response.encodeRedirectURL(buf.toString()));
99 /**
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);
108 if (ifms != null) {
109 long ifmsl = -1;
110 try {
111 ifmsl = request.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE);
112 } catch (IllegalArgumentException e) {
114 if (ifmsl != -1) {
115 if (resource.lastModified() <= ifmsl) {
116 response.reset();
117 response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
118 response.flushBuffer();
119 return false;
124 long date = -1;
125 try {
126 date = request.getDateHeader(HttpHeaders.IF_UNMODIFIED_SINCE);
127 } catch (IllegalArgumentException e) {
129 if (date != -1) {
130 if (resource.lastModified() > date) {
131 response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
132 return false;
136 return true;
140 * Write or include the specified resource.
142 public void sendData(HttpServletRequest request,
143 HttpServletResponse response,
144 boolean include,
145 Resource resource) throws IOException {
146 long contentLength = resource.length();
147 if (!include) {
148 writeHeaders(response, request.getRequestURI(), resource, contentLength);
151 OutputStream out = null;
152 try {
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,
164 long count) {
165 if (count != -1) {
166 if (count < Integer.MAX_VALUE) {
167 response.setContentLength((int) count);
168 } else {
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.
201 @VisibleForTesting
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);
219 break;
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.
229 * <p>
230 * We follow the same logic that is used in production App Engine. This includes:
231 * <ul>
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
242 * </ul>
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.
248 * <p>
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");
268 return;
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());
276 return;
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.
290 @VisibleForTesting
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) {
299 expirationSeconds +=
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)) {
329 case 'd':
330 numSeconds *= 24 * 60 * 60;
331 break;
332 case 'h':
333 numSeconds *= 60 * 60;
334 break;
335 case 'm':
336 numSeconds *= 60;
337 break;
340 return numSeconds;
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) {
355 int seconds = 0;
356 try {
357 seconds = Integer.parseInt(intString);
358 } catch (NumberFormatException e) {
359 throwExpirationParseException(componentSpecifier, fullSpecifier);
361 if (seconds < 0) {
362 throwExpirationParseException(componentSpecifier, fullSpecifier);
364 return seconds;
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 + "'");