All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.appengine.tools.development.jetty9.StaticFileUtils Maven / Gradle / Ivy

/*
 * Copyright 2021 Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.appengine.tools.development.jetty9;

import com.google.apphosting.utils.config.AppEngineWebXml;
import com.google.common.annotations.VisibleForTesting;
import java.io.IOException;
import java.io.OutputStream;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.io.WriterOutputStream;
import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.util.URIUtil;
import org.eclipse.jetty.util.resource.Resource;

/**
 * {@code StaticFileUtils} is a collection of utilities shared by
 * {@link LocalResourceFileServlet} and {@link StaticFileFilter}.
 *
 */
public class StaticFileUtils {
  private static final String DEFAULT_CACHE_CONTROL_VALUE = "public, max-age=600";

  private final ContextHandler.Context servletContext;

  public StaticFileUtils(ContextHandler.Context servletContext) {
    this.servletContext = servletContext;
  }

  public boolean serveWelcomeFileAsRedirect(String path,
                                            boolean included,
                                            HttpServletRequest request,
                                            HttpServletResponse response)
      throws IOException {
    if (included) {
      // This is an error.  We don't have the file so we can't
      // include it in the request.
      return false;
    }

    // Even if the trailing slash is missing, don't bother trying to
    // add it.  We're going to redirect to a full file anyway.
    response.setContentLength(0);
    String q = request.getQueryString();
    if (q != null && q.length() != 0) {
      response.sendRedirect(path + "?" + q);
    } else {
      response.sendRedirect(path);
    }
    return true;
  }

  public boolean serveWelcomeFileAsForward(RequestDispatcher dispatcher,
                                           boolean included,
                                           HttpServletRequest request,
                                           HttpServletResponse response)
      throws IOException, ServletException {
    // If the user didn't specify a slash but we know we want a
    // welcome file, redirect them to add the slash now.
    if (!included && !request.getRequestURI().endsWith(URIUtil.SLASH)) {
      redirectToAddSlash(request, response);
      return true;
    }

    request.setAttribute("com.google.appengine.tools.development.isWelcomeFile", true);
    if (dispatcher != null) {
      if (included) {
        dispatcher.include(request, response);
      } else {
        dispatcher.forward(request, response);
      }
      return true;
    }
    return false;
  }

  public void redirectToAddSlash(HttpServletRequest request, HttpServletResponse response)
      throws IOException {
    StringBuffer buf = request.getRequestURL();
    int param = buf.lastIndexOf(";");
    if (param < 0) {
      buf.append('/');
    } else {
      buf.insert(param, '/');
    }
    String q = request.getQueryString();
    if (q != null && q.length() != 0) {
      buf.append('?');
      buf.append(q);
    }
    response.setContentLength(0);
    response.sendRedirect(response.encodeRedirectURL(buf.toString()));
  }

  /**
   * Check the headers to see if content needs to be sent.
   * @return true if the content should be sent, false otherwise.
   */
  public boolean passConditionalHeaders(HttpServletRequest request,
                                         HttpServletResponse response,
                                         Resource resource) throws IOException {
    if (!request.getMethod().equals(HttpMethod.HEAD.asString())) {
      String ifms = request.getHeader(HttpHeader.IF_MODIFIED_SINCE.asString());
      if (ifms != null) {
        long ifmsl = -1;
        try {
          ifmsl = request.getDateHeader(HttpHeader.IF_MODIFIED_SINCE.asString());
        } catch (IllegalArgumentException e) {
          // Ignore bad date formats.
        }
        if (ifmsl != -1) {
          if (resource.lastModified() <= ifmsl) {
            response.reset();
            response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
            response.flushBuffer();
            return false;
          }
        }
      }

      // Parse the if[un]modified dates and compare to resource
      long date = -1;
      try {
        date = request.getDateHeader(HttpHeader.IF_UNMODIFIED_SINCE.asString());
      } catch (IllegalArgumentException e) {
        // Ignore bad date formats.
      }
      if (date != -1) {
        if (resource.lastModified() > date) {
          response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
          return false;
        }
      }
    }
    return true;
  }

  /**
   * Write or include the specified resource.
   */
  public void sendData(HttpServletRequest request,
                        HttpServletResponse response,
                        boolean include,
                        Resource resource) throws IOException {
    long contentLength = resource.length();
    if (!include) {
      writeHeaders(response, request.getRequestURI(), resource, contentLength);
    }

    // Get the output stream (or writer)
    OutputStream out = null;
    try {
      out = response.getOutputStream();
    } catch (IllegalStateException e) {
      out = new WriterOutputStream(response.getWriter());
    }
    resource.writeTo(out, 0, contentLength);
  }

  /**
   * Write the headers that should accompany the specified resource.
   */
  public void writeHeaders(
      HttpServletResponse response, String requestPath, Resource resource, long count) {
    // Set Content-Length. Users are not allowed to override this. Therefore, we
    // may do this before adding custom static headers.
    if (count != -1) {
      if (count < Integer.MAX_VALUE) {
        response.setContentLength((int) count);
      } else {
        response.setHeader(HttpHeader.CONTENT_LENGTH.asString(), String.valueOf(count));
      }
    }

    Set headersApplied = addUserStaticHeaders(requestPath, response);

    // Set Content-Type.
    if (!headersApplied.contains("content-type")) {
      String contentType = servletContext.getMimeType(resource.getName());
      if (contentType != null) {
        response.setContentType(contentType);
      }
    }

    // Set Last-Modified.
    if (!headersApplied.contains("last-modified")) {
      response.setDateHeader(HttpHeader.LAST_MODIFIED.asString(), resource.lastModified());
    }

    // Set Cache-Control to the default value if it was not explicitly set.
    if (!headersApplied.contains(HttpHeader.CACHE_CONTROL.asString().toLowerCase())) {
      response.setHeader(HttpHeader.CACHE_CONTROL.asString(), DEFAULT_CACHE_CONTROL_VALUE);
    }
  }

  /**
   * Adds HTTP Response headers that are specified in appengine-web.xml. The user may specify
   * headers explicitly using the {@code http-header} element. Also the user may specify cache
   * expiration headers implicitly using the {@code expiration} attribute. There is no check for
   * consistency between different specified headers.
   *
   * @param localFilePath The path to the static file being served.
   * @param response The HttpResponse object to which headers will be added
   * @return The Set of the names of all headers that were added, canonicalized to lower case.
   */
  @VisibleForTesting
  Set addUserStaticHeaders(String localFilePath, HttpServletResponse response) {
    AppEngineWebXml appEngineWebXml =
        (AppEngineWebXml)
            servletContext.getAttribute("com.google.appengine.tools.development.appEngineWebXml");

    Set headersApplied = new HashSet<>();
    for (AppEngineWebXml.StaticFileInclude include : appEngineWebXml.getStaticFileIncludes()) {
      Pattern pattern = include.getRegularExpression();
      if (pattern.matcher(localFilePath).matches()) {
        for (Map.Entry entry : include.getHttpHeaders().entrySet()) {
          response.addHeader(entry.getKey(), entry.getValue());
          headersApplied.add(entry.getKey().toLowerCase());
        }
        String expirationString = include.getExpiration();
        if (expirationString != null) {
          addCacheControlHeaders(headersApplied, expirationString, response);
        }
        break;
      }
    }
    return headersApplied;
  }

  /**
   * Adds HTTP headers to the response to describe cache expiration behavior, based on the
   * {@code expires} attribute of the {@code includes} element of the {@code static-files} element
   * of appengine-web.xml.
   * 

* We follow the same logic that is used in production App Engine. This includes: *

    *
  • There is no coordination between these headers (implied by the 'expires' attribute) and * explicitly specified headers (expressed with the 'http-header' sub-element). If the user * specifies contradictory headers then we will include contradictory headers. *
  • If the expiration time is zero then we specify that the response should not be cached using * three different headers: {@code Pragma: no-cache}, {@code Expires: 0} and * {@code Cache-Control: no-cache, must-revalidate}. *
  • If the expiration time is positive then we specify that the response should be cached for * that many seconds using two different headers: {@code Expires: num-seconds} and * {@code Cache-Control: public, max-age=num-seconds}. *
  • If the expiration time is not specified then we use a default value of 10 minutes *
* * Note that there is one aspect of the production App Engine logic that is not replicated here. * In production App Engine if the url to a static file is protected by a security constraint in * web.xml then {@code Cache-Control: private} is used instead of {@code Cache-Control: public}. * In the development App Server {@code Cache-Control: public} is always used. *

* Also if the expiration time is specified but cannot be parsed as a non-negative number of * seconds then a RuntimeException is thrown. * * @param headersApplied Set of headers that have been applied, canonicalized to lower-case. Any * new headers applied in this method will be added to the set. * @param expiration The expiration String specified in appengine-web.xml * @param response The HttpServletResponse into which we will write the HTTP headers. */ private static void addCacheControlHeaders( Set headersApplied, String expiration, HttpServletResponse response) { // The logic in this method is replicating and should be kept in sync with // the corresponding logic in production App Engine which is implemented // in AppServerResponse::SetExpiration() in the file // apphosting/appserver/appserver_response.cc. See also // HTTPResponse::SetNotCacheable(), HTTPResponse::SetCacheablePrivate(), // and HTTPResponse::SetCacheablePublic() in webutil/http/httpresponse.cc int expirationSeconds = parseExpirationSpecifier(expiration); if (expirationSeconds == 0) { response.addHeader("Pragma", "no-cache"); response.addHeader(HttpHeader.CACHE_CONTROL.asString(), "no-cache, must-revalidate"); response.addDateHeader(HttpHeader.EXPIRES.asString(), 0); headersApplied.add(HttpHeader.CACHE_CONTROL.asString().toLowerCase()); headersApplied.add(HttpHeader.EXPIRES.asString().toLowerCase()); headersApplied.add("pragma"); return; } if (expirationSeconds > 0) { // TODO If we wish to support the corresponding logic // in production App Engine, we would now determine if the current // request URL is protected by a security constraint in web.xml and // if so we would use Cache-Control: private here instead of public. response.addHeader( HttpHeader.CACHE_CONTROL.asString(), "public, max-age=" + expirationSeconds); response.addDateHeader( HttpHeader.EXPIRES.asString(), System.currentTimeMillis() + expirationSeconds * 1000L); headersApplied.add(HttpHeader.CACHE_CONTROL.asString().toLowerCase()); headersApplied.add(HttpHeader.EXPIRES.asString().toLowerCase()); return; } throw new RuntimeException("expirationSeconds is negative: " + expirationSeconds); } /** * Parses an expiration specifier String and returns the number of seconds it represents. A valid * expiration specifier is a white-space-delimited list of components, each of which is a sequence * of digits, optionally followed by a single letter from the set {D, d, H, h, M, m, S, s}. For * example {@code 21D 4H 30m} represents the number of seconds in 21 days, 4.5 hours. * * @param expirationSpecifier The non-null, non-empty expiration specifier String to parse * @return The non-negative number of seconds represented by this String. */ @VisibleForTesting static int parseExpirationSpecifier(String expirationSpecifier) { // The logic in this and the following few methods is replicating and should be kept in // sync with the corresponding logic in production App Engine which is implemented in // apphosting/api/appinfo.py. See in particular in that file _DELTA_REGEX, // _EXPIRATION_REGEX, _EXPIRATION_CONVERSION, and ParseExpiration(). expirationSpecifier = expirationSpecifier.trim(); if (expirationSpecifier.isEmpty()) { throwExpirationParseException("", expirationSpecifier); } String[] components = expirationSpecifier.split("(\\s)+"); int expirationSeconds = 0; for (String componentSpecifier : components) { expirationSeconds += parseExpirationSpeciferComponent(componentSpecifier, expirationSpecifier); } return expirationSeconds; } // A Pattern for matching one component of an expiration specifier String private static final Pattern EXPIRATION_COMPONENT_PATTERN = Pattern.compile("^(\\d+)([dhms]?)$"); /** * Parses a single component of an expiration specifier, and returns the number of seconds that * the component represents. A valid component specifier is a sequence of digits, optionally * followed by a single letter from the set {D, d, H, h, M, m, S, s}, indicating days, hours, * minutes and seconds. A lack of a trailing letter is interpreted as seconds. * * @param componentSpecifier The component specifier to parse * @param fullSpecifier The full specifier of which {@code componentSpecifier} is a component. * This will be included in an error message if necessary. * @return The number of seconds represented by {@code componentSpecifier} */ private static int parseExpirationSpeciferComponent( String componentSpecifier, String fullSpecifier) { Matcher matcher = EXPIRATION_COMPONENT_PATTERN.matcher(componentSpecifier.toLowerCase()); if (!matcher.matches()) { throwExpirationParseException(componentSpecifier, fullSpecifier); } String numericString = matcher.group(1); int numSeconds = parseExpirationInteger(numericString, componentSpecifier, fullSpecifier); String unitString = matcher.group(2); if (unitString.length() > 0) { switch (unitString.charAt(0)) { case 'd': numSeconds *= 24 * 60 * 60; break; case 'h': numSeconds *= 60 * 60; break; case 'm': numSeconds *= 60; break; } } return numSeconds; } /** * Parses a String from an expiration specifier as a non-negative integer. If successful returns * the integer. Otherwise throws an {@link IllegalArgumentException} indicating that the specifier * could not be parsed. * * @param intString String to parse * @param componentSpecifier The component of the specifier being parsed * @param fullSpecifier The full specifier * @return The parsed integer */ private static int parseExpirationInteger( String intString, String componentSpecifier, String fullSpecifier) { int seconds = 0; try { seconds = Integer.parseInt(intString); } catch (NumberFormatException e) { throwExpirationParseException(componentSpecifier, fullSpecifier); } if (seconds < 0) { throwExpirationParseException(componentSpecifier, fullSpecifier); } return seconds; } /** * Throws an {@link IllegalArgumentException} indicating that an expiration specifier String was * not able to be parsed. * * @param componentSpecifier The component that could not be parsed * @param fullSpecifier The full String */ private static void throwExpirationParseException( String componentSpecifier, String fullSpecifier) { throw new IllegalArgumentException( "Unable to parse cache expiration specifier '" + fullSpecifier + "' at component '" + componentSpecifier + "'"); } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy