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

com.phloc.web.servlet.response.UnifiedResponse Maven / Gradle / Ivy

/**
 * Copyright (C) 2006-2015 phloc systems
 * http://www.phloc.com
 * office[at]phloc[dot]com
 *
 * 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
 *
 *         http://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.phloc.web.servlet.response;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.Charset;
import java.nio.charset.CharsetEncoder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

import javax.annotation.Nonnegative;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.NotThreadSafe;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.joda.time.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.phloc.commons.CGlobal;
import com.phloc.commons.GlobalDebug;
import com.phloc.commons.ValueEnforcer;
import com.phloc.commons.annotations.Nonempty;
import com.phloc.commons.annotations.ReturnsMutableObject;
import com.phloc.commons.charset.CCharset;
import com.phloc.commons.charset.CharsetManager;
import com.phloc.commons.collections.ContainerHelper;
import com.phloc.commons.io.IInputStreamProvider;
import com.phloc.commons.io.file.FilenameHelper;
import com.phloc.commons.io.streams.StreamUtils;
import com.phloc.commons.mime.CMimeType;
import com.phloc.commons.mime.IMimeType;
import com.phloc.commons.mime.MimeTypeParser;
import com.phloc.commons.mutable.MutableLong;
import com.phloc.commons.state.EChange;
import com.phloc.commons.string.StringHelper;
import com.phloc.commons.url.ISimpleURL;
import com.phloc.commons.url.URLUtils;
import com.phloc.datetime.PDTFactory;
import com.phloc.web.encoding.RFC5987Encoder;
import com.phloc.web.http.AcceptCharsetHandler;
import com.phloc.web.http.AcceptCharsetList;
import com.phloc.web.http.AcceptMimeTypeHandler;
import com.phloc.web.http.AcceptMimeTypeList;
import com.phloc.web.http.CHTTPHeader;
import com.phloc.web.http.CacheControlBuilder;
import com.phloc.web.http.EHTTPMethod;
import com.phloc.web.http.EHTTPVersion;
import com.phloc.web.http.HTTPHeaderMap;
import com.phloc.web.http.QValue;
import com.phloc.web.servlet.request.RequestHelper;
import com.phloc.web.servlet.request.RequestLogger;
import com.phloc.web.useragent.browser.BrowserInfo;
import com.phloc.web.useragent.browser.EBrowserType;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;

/**
 * This class encapsulates all things required to build a HTTP response. It
 * offer warnings and consistency checks if something is missing.
 * 
 * @author Philip Helger
 */
@NotThreadSafe
public class UnifiedResponse
{
  /** By default content is not allowed on redirect */
  public static final boolean DEFAULT_ALLOW_CONTENT_ON_REDIRECT = false;
  /** By default content is not allowed for status codes */
  public static final boolean DEFAULT_ALLOW_CONTENT_ON_STATUS_CODE = false;
  /** Default content disposition type is Attachment */
  public static final EContentDispositionType DEFAULT_CONTENT_DISPOSITION_TYPE = EContentDispositionType.ATTACHMENT;
  /** Maximum KB a CSS file might have in IE */
  public static final int MAX_CSS_KB_FOR_IE = 288;

  private static final Logger s_aLogger = LoggerFactory.getLogger (UnifiedResponse.class);
  private static final AtomicInteger s_aRequestNum = new AtomicInteger (0);

  // Input fields
  private final EHTTPVersion m_eHTTPVersion;
  private final EHTTPMethod m_eHTTPMethod;
  private final HttpServletRequest m_aHttpRequest;
  private final AcceptCharsetList m_aAcceptCharsetList;
  private final AcceptMimeTypeList m_aAcceptMimeTypeList;
  private BrowserInfo m_aRequestBrowserInfo;

  // Settings
  /**
   * Flag which determines whether content is allow, if a redirect is set. This
   * is rarely used.
   */
  private boolean m_bAllowContentOnRedirect = DEFAULT_ALLOW_CONTENT_ON_REDIRECT;

  /**
   * Flag which determines whether content is allow, if a status code is set.
   * This is rarely used.
   */
  private boolean m_bAllowContentOnStatusCode = DEFAULT_ALLOW_CONTENT_ON_STATUS_CODE;

  // Main response fields
  private Charset m_aCharset;
  private IMimeType m_aMimeType;
  private byte [] m_aContent;
  private IInputStreamProvider m_aContentISP;
  private EContentDispositionType m_eContentDispositionType = DEFAULT_CONTENT_DISPOSITION_TYPE;
  private String m_sContentDispositionFilename;
  private CacheControlBuilder m_aCacheControl;
  private final HTTPHeaderMap m_aResponseHeaderMap = new HTTPHeaderMap ();
  private int m_nStatusCode = CGlobal.ILLEGAL_UINT;
  private String m_sRedirectTargetUrl;
  private Map  m_aCookies;

  // Internal status members
  /**
   * Unique internal ID for each response, so that error messages can be more
   * easily aggregated.
   */
  private final int m_nID = s_aRequestNum.incrementAndGet ();

  /**
   * The request URL, lazily initialized.
   */
  private String m_sRequestURL;

  /**
   * Just avoid emitting the request headers more than once, as they wont change
   * from error to error.
   */
  private boolean m_bEmittedRequestHeaders = false;

  /** This maps keeps all the response headers for later emitting. */
  private final HTTPHeaderMap m_aRequestHeaderMap;

  /**
   * An optional encode to be used to determine if a content-disposition
   * filename can be ISO-8859-1 encoded.
   */
  private CharsetEncoder m_aContentDispositionEncoder;

  /**
   * Constructor
   * 
   * @param aHttpRequest
   *        The main HTTP request
   */
  public UnifiedResponse (@Nonnull final HttpServletRequest aHttpRequest)
  {
    this (RequestHelper.getHttpVersion (aHttpRequest), RequestHelper.getHttpMethod (aHttpRequest), aHttpRequest);
  }

  /**
   * Constructor
   * 
   * @param eHTTPVersion
   *        HTTP version of this request (1.0 or 1.1)
   * @param eHTTPMethod
   *        HTTP method of this request (GET, POST, ...)
   * @param aHttpRequest
   *        The main HTTP request
   */
  public UnifiedResponse (@Nonnull final EHTTPVersion eHTTPVersion,
                          @Nonnull final EHTTPMethod eHTTPMethod,
                          @Nonnull final HttpServletRequest aHttpRequest)
  {
    m_eHTTPVersion = ValueEnforcer.notNull (eHTTPVersion, "HTTPVersion");
    m_eHTTPMethod = ValueEnforcer.notNull (eHTTPMethod, "HTTPMethod");
    m_aHttpRequest = ValueEnforcer.notNull (aHttpRequest, "HTTPRequest");
    m_aAcceptCharsetList = AcceptCharsetHandler.getAcceptCharsets (aHttpRequest);
    m_aAcceptMimeTypeList = AcceptMimeTypeHandler.getAcceptMimeTypes (aHttpRequest);
    m_aRequestHeaderMap = RequestHelper.getRequestHeaderMap (aHttpRequest);
  }

  @Nonnull
  @Nonempty
  private String _getPrefix ()
  {
    if (m_sRequestURL == null)
      m_sRequestURL = RequestHelper.getURL (m_aHttpRequest);
    return "UnifiedResponse[" + m_nID + "] to [" + m_sRequestURL + "]: ";
  }

  private void _info (@Nonnull final String sMsg)
  {
    s_aLogger.info (_getPrefix () + sMsg);
  }

  private void _showRequestInfo ()
  {
    if (!m_bEmittedRequestHeaders)
    {
      s_aLogger.warn ("  Request Headers: " +
                      ContainerHelper.getSortedByKey (RequestLogger.getHTTPHeaderMap (m_aRequestHeaderMap)));
      if (!m_aResponseHeaderMap.isEmpty ())
        s_aLogger.warn ("  Response Headers: " +
                        ContainerHelper.getSortedByKey (RequestLogger.getHTTPHeaderMap (m_aResponseHeaderMap)));
      m_bEmittedRequestHeaders = true;
    }
  }

  private void _warn (@Nonnull final String sMsg)
  {
    s_aLogger.warn (_getPrefix () + sMsg);
    _showRequestInfo ();
  }

  private void _error (@Nonnull final String sMsg)
  {
    s_aLogger.error (_getPrefix () + sMsg);
    _showRequestInfo ();
  }

  @Nonnull
  public final EHTTPVersion getHTTPVersion ()
  {
    return m_eHTTPVersion;
  }

  @Nonnull
  public final EHTTPMethod getHTTPMethod ()
  {
    return m_eHTTPMethod;
  }

  @Nullable
  public BrowserInfo getRequestBrowserInfo ()
  {
    return m_aRequestBrowserInfo;
  }

  @Nonnull
  public UnifiedResponse setRequestBrowserInfo (@Nullable final BrowserInfo aRequestBrowserInfo)
  {
    m_aRequestBrowserInfo = aRequestBrowserInfo;
    return this;
  }

  /**
   * @return true if content is allowed even if a redirect is
   *         present.
   */
  public boolean isAllowContentOnRedirect ()
  {
    return m_bAllowContentOnRedirect;
  }

  @Nonnull
  public UnifiedResponse setAllowContentOnRedirect (final boolean bAllowContentOnRedirect)
  {
    m_bAllowContentOnRedirect = bAllowContentOnRedirect;
    return this;
  }

  /**
   * @return true if content is allowed even if a status code is
   *         present.
   */
  public boolean isAllowContentOnStatusCode ()
  {
    return m_bAllowContentOnStatusCode;
  }

  @Nonnull
  public UnifiedResponse setAllowContentOnStatusCode (final boolean bAllowContentOnStatusCode)
  {
    m_bAllowContentOnStatusCode = bAllowContentOnStatusCode;
    return this;
  }

  @Nullable
  public Charset getCharset ()
  {
    return m_aCharset;
  }

  @Nonnull
  public UnifiedResponse setCharset (@Nonnull final Charset aCharset)
  {
    ValueEnforcer.notNull (aCharset, "Charset");
    if (m_aCharset != null)
      _info ("Overwriting charset from " + m_aCharset + " to " + aCharset);
    m_aCharset = aCharset;
    return this;
  }

  @Nonnull
  public UnifiedResponse removeCharset ()
  {
    m_aCharset = null;
    return this;
  }

  @Nullable
  public IMimeType getMimeType ()
  {
    return m_aMimeType;
  }

  @Nonnull
  public UnifiedResponse setMimeType (@Nonnull final IMimeType aMimeType)
  {
    ValueEnforcer.notNull (aMimeType, "MimeType");
    if (m_aMimeType != null)
      _info ("Overwriting MimeType from " + m_aMimeType + " to " + aMimeType);
    m_aMimeType = aMimeType;
    return this;
  }

  @Nonnull
  public UnifiedResponse setMimeTypeString (@Nonnull @Nonempty final String sMimeType)
  {
    ValueEnforcer.notEmpty (sMimeType, "MimeType");

    final IMimeType aMimeType = MimeTypeParser.parseMimeType (sMimeType);
    if (aMimeType != null)
      setMimeType (aMimeType);
    else
      _error ("Failed to resolve mime type from '" + sMimeType + "'");
    return this;
  }

  @Nonnull
  public UnifiedResponse removeMimeType ()
  {
    m_aMimeType = null;
    return this;
  }

  /**
   * @return true if a content was already set, false
   *         if not.
   */
  public boolean hasContent ()
  {
    return m_aContent != null || m_aContentISP != null;
  }

  /**
   * Utility method to set an empty response content.
   * 
   * @return this
   */
  @Nonnull
  public UnifiedResponse setEmptyContent ()
  {
    return setContent (new byte [0]);
  }

  /**
   * Utility method to set content and charset at once.
   * 
   * @param sContent
   *        The response content string. May not be null.
   * @param aCharset
   *        The charset to use. May not be null.
   * @return this
   */
  @Nonnull
  public UnifiedResponse setContentAndCharset (@Nonnull final String sContent, @Nonnull final Charset aCharset)
  {
    ValueEnforcer.notNull (sContent, "Content");
    setCharset (aCharset);
    setContent (CharsetManager.getAsBytes (sContent, aCharset));
    return this;
  }

  /**
   * Set the response content. To return an empty response pass in a new empty
   * array, but not null.
   * 
   * @param aContent
   *        The content to be returned. Is not copied inside! May not be
   *        null but maybe empty.
   * @return this
   */
  @Nonnull
  @SuppressFBWarnings ("EI_EXPOSE_REP2")
  public UnifiedResponse setContent (@Nonnull final byte [] aContent)
  {
    ValueEnforcer.notNull (aContent, "Content");
    if (hasContent ())
      _info ("Overwriting content with byte array!");
    m_aContent = aContent;
    m_aContentISP = null;
    return this;
  }

  /**
   * Set the response content provider.
   * 
   * @param aISP
   *        The content provider to be used. May not be null.
   * @return this
   */
  @Nonnull
  public UnifiedResponse setContent (@Nonnull final IInputStreamProvider aISP)
  {
    ValueEnforcer.notNull (aISP, "InputStreamProvider");
    if (hasContent ())
      _info ("Overwriting content with content provider!");
    m_aContent = null;
    m_aContentISP = aISP;
    return this;
  }

  @Nonnull
  public UnifiedResponse removeContent ()
  {
    m_aContent = null;
    m_aContentISP = null;
    return this;
  }

  @Nonnull
  public UnifiedResponse setExpires (@Nonnull final DateTime aDT)
  {
    m_aResponseHeaderMap.setDateHeader (CHTTPHeader.EXPIRES, aDT);
    return this;
  }

  @Nonnull
  public UnifiedResponse removeExpires ()
  {
    m_aResponseHeaderMap.removeHeaders (CHTTPHeader.EXPIRES);
    return this;
  }

  @Nonnull
  public UnifiedResponse setLastModified (@Nonnull final DateTime aDT)
  {
    if (m_eHTTPMethod != EHTTPMethod.GET && m_eHTTPMethod != EHTTPMethod.HEAD)
      _warn ("Setting Last-Modified on a non GET or HEAD request may have no impact!");

    m_aResponseHeaderMap.setDateHeader (CHTTPHeader.LAST_MODIFIED, aDT);
    return this;
  }

  @Nonnull
  public UnifiedResponse removeLastModified ()
  {
    m_aResponseHeaderMap.removeHeaders (CHTTPHeader.LAST_MODIFIED);
    return this;
  }

  /**
   * Set an ETag for the response. The ETag must be a quoted value (being
   * surrounded by double quotes).
   * 
   * @param sETag
   *        The quoted ETag to be set. May neither be null nor
   *        empty.
   * @return this
   */
  @Nonnull
  public UnifiedResponse setETag (@Nonnull @Nonempty final String sETag)
  {
    ValueEnforcer.notEmpty (sETag, "ETag");
    if (!sETag.startsWith ("\"") && !sETag.startsWith ("W/\""))
      throw new IllegalArgumentException ("Etag must start with a '\"' character or with 'W/\"': " + sETag);
    if (!sETag.endsWith ("\""))
      throw new IllegalArgumentException ("Etag must end with a '\"' character: " + sETag);
    if (m_eHTTPMethod != EHTTPMethod.GET)
      _warn ("Setting an ETag on a non-GET request may have no impact!");

    m_aResponseHeaderMap.setHeader (CHTTPHeader.ETAG, sETag);
    return this;
  }

  /**
   * Set an ETag for the response if this is an HTTP/1.1 response. HTTP/1.0 does
   * not support ETags. The ETag must be a quoted value (being surrounded by
   * double quotes).
   * 
   * @param sETag
   *        The quoted ETag to be set. May neither be null nor
   *        empty.
   * @return this
   */
  @Nonnull
  public UnifiedResponse setETagIfApplicable (@Nonnull @Nonempty final String sETag)
  {
    if (m_eHTTPVersion == EHTTPVersion.HTTP_11)
      setETag (sETag);
    return this;
  }

  @Nonnull
  public UnifiedResponse removeETag ()
  {
    m_aResponseHeaderMap.removeHeaders (CHTTPHeader.ETAG);
    return this;
  }

  @Nonnull
  public UnifiedResponse setContentDispositionType (@Nonnull final EContentDispositionType eContentDispositionType)
  {
    ValueEnforcer.notNull (eContentDispositionType, "ContentDispositionType");

    m_eContentDispositionType = eContentDispositionType;
    return this;
  }

  @Nonnull
  public EContentDispositionType getContentDispositionType ()
  {
    return m_eContentDispositionType;
  }

  @Nonnull
  public UnifiedResponse setContentDispositionFilename (@Nonnull @Nonempty final String sFilename)
  {
    ValueEnforcer.notEmpty (sFilename, "Filename");

    // Ensure that a valid filename is used
    // -> Strip all paths and replace all invalid characters
    final String sFilenameToUse = FilenameHelper.getWithoutPath (FilenameHelper.getAsSecureValidFilename (sFilename));
    if (!sFilename.equals (sFilenameToUse))
      _warn ("Content-Dispostion filename was internally modified from '" + sFilename + "' to '" + sFilenameToUse + "'");

    // Disabled because of the extended UTF-8 handling (RFC 5987)
    if (false)
    {
      // Check if encoding as ISO-8859-1 is possible
      if (m_aContentDispositionEncoder == null)
        m_aContentDispositionEncoder = CCharset.CHARSET_ISO_8859_1_OBJ.newEncoder ();
      if (!m_aContentDispositionEncoder.canEncode (sFilenameToUse))
        _error ("Content-Dispostion filename '" + sFilenameToUse + "' cannot be encoded to ISO-8859-1!");
    }

    // Are we overwriting?
    if (m_sContentDispositionFilename != null)
      _info ("Overwriting Content-Dispostion filename from '" +
             m_sContentDispositionFilename +
             "' to '" +
             sFilenameToUse +
             "'");

    // No URL encoding necessary.
    // Filename must be in ISO-8859-1
    // See http://greenbytes.de/tech/tc2231/
    m_sContentDispositionFilename = sFilenameToUse;
    return this;
  }

  @Nullable
  public String getContentDispositionFilename ()
  {
    return m_sContentDispositionFilename;
  }

  @Nonnull
  public UnifiedResponse removeContentDispositionFilename ()
  {
    m_sContentDispositionFilename = null;
    return this;
  }

  /**
   * Utility method for setting the MimeType application/force-download and set
   * the respective content disposition filename.
   * 
   * @param sFilename
   *        The filename to be used.
   * @return this
   */
  @Nonnull
  public UnifiedResponse setDownloadFilename (@Nonnull @Nonempty final String sFilename)
  {
    setMimeType (CMimeType.APPLICATION_FORCE_DOWNLOAD);
    setContentDispositionFilename (sFilename);
    return this;
  }

  @Nonnull
  public UnifiedResponse setCacheControl (@Nonnull final CacheControlBuilder aCacheControl)
  {
    ValueEnforcer.notNull (aCacheControl, "CacheControl");

    if (m_aCacheControl != null)
      _info ("Overwriting Cache-Control data from '" +
             m_aCacheControl.getAsHTTPHeaderValue () +
             "' to '" +
             aCacheControl.getAsHTTPHeaderValue () +
             "'");
    m_aCacheControl = aCacheControl;
    return this;
  }

  @Nullable
  @ReturnsMutableObject (reason = "Design")
  public CacheControlBuilder getCacheControl ()
  {
    return m_aCacheControl;
  }

  @Nonnull
  public UnifiedResponse removeCacheControl ()
  {
    m_aCacheControl = null;
    return this;
  }

  /**
   * A utility method that disables caching for this response.
   * 
   * @return this
   */
  @Nonnull
  public UnifiedResponse disableCaching ()
  {
    // Remove any eventually set headers
    removeExpires ();
    removeCacheControl ();
    removeETag ();
    removeLastModified ();
    m_aResponseHeaderMap.removeHeaders (CHTTPHeader.PRAGMA);

    switch (m_eHTTPVersion)
    {
      case HTTP_10:
      {
        // Set to expire far in the past for HTTP/1.0.
        m_aResponseHeaderMap.setHeader (CHTTPHeader.EXPIRES, ResponseHelperSettings.EXPIRES_NEVER_STRING);

        // Set standard HTTP/1.0 no-cache header.
        m_aResponseHeaderMap.setHeader (CHTTPHeader.PRAGMA, "no-cache");
        break;
      }
      case HTTP_11:
      {
        final CacheControlBuilder aCacheControlBuilder = new CacheControlBuilder ().setNoStore (true)
                                                                                   .setNoCache (true)
                                                                                   .setMustRevalidate (true)
                                                                                   .setProxyRevalidate (true);

        // Set IE extended HTTP/1.1 no-cache headers.
        // http://aspnetresources.com/blog/cache_control_extensions
        // Disabled because:
        // http://blogs.msdn.com/b/ieinternals/archive/2009/07/20/using-post_2d00_check-and-pre_2d00_check-cache-directives.aspx
        if (false)
          aCacheControlBuilder.addExtension ("post-check=0").addExtension ("pre-check=0");

        setCacheControl (aCacheControlBuilder);
        break;
      }
    }
    return this;
  }

  /**
   * Enable caching of this resource for the specified number of seconds.
   * 
   * @param nSeconds
   *        The number of seconds caching is allowed. Must be > 0.
   * @return this
   */
  @Nonnull
  public UnifiedResponse enableCaching (@Nonnegative final int nSeconds)
  {
    ValueEnforcer.isGT0 (nSeconds, "Seconds");

    // Remove any eventually set headers
    // Note: don't remove Last-Modified and ETag!
    removeExpires ();
    removeCacheControl ();
    m_aResponseHeaderMap.removeHeaders (CHTTPHeader.PRAGMA);

    switch (m_eHTTPVersion)
    {
      case HTTP_10:
      {
        m_aResponseHeaderMap.setDateHeader (CHTTPHeader.EXPIRES, PDTFactory.getCurrentDateTime ()
                                                                           .plusSeconds (nSeconds));
        break;
      }
      case HTTP_11:
      {
        final CacheControlBuilder aCacheControlBuilder = new CacheControlBuilder ().setPublic (true)
                                                                                   .setMaxAgeSeconds (nSeconds);
        setCacheControl (aCacheControlBuilder);
        break;
      }
    }
    return this;
  }

  private void _setStatus (@Nonnegative final int nStatusCode)
  {
    if (m_nStatusCode != CGlobal.ILLEGAL_UINT)
      _info ("Overwriting status code " + m_nStatusCode + " with " + nStatusCode);
    m_nStatusCode = nStatusCode;
  }

  /**
   * Set the status code to be returned from the response.
   * 
   * @param nStatusCode
   *        The status code to be set. Must be a valid HTTP response code.
   * @return this
   */
  @Nonnull
  public UnifiedResponse setStatus (@Nonnegative final int nStatusCode)
  {
    _setStatus (nStatusCode);
    return this;
  }

  /**
   * Special handling for returning status code 401 UNAUTHORIZED.
   * 
   * @param sAuthenticate
   *        The string to be used for the {@link CHTTPHeader#WWW_AUTHENTICATE}
   *        response header. May be null or empty.
   * @return this
   */
  @Nonnull
  public UnifiedResponse setStatusUnauthorized (@Nullable final String sAuthenticate)
  {
    _setStatus (HttpServletResponse.SC_UNAUTHORIZED);
    if (StringHelper.hasText (sAuthenticate))
      m_aResponseHeaderMap.setHeader (CHTTPHeader.WWW_AUTHENTICATE, sAuthenticate);
    return this;
  }

  @Nonnull
  public UnifiedResponse setRedirect (@Nonnull final ISimpleURL aRedirectTargetUrl)
  {
    ValueEnforcer.notNull (aRedirectTargetUrl, "RedirectTargetUrl");

    return setRedirect (aRedirectTargetUrl.getAsString ());
  }

  @Nonnull
  public UnifiedResponse setRedirect (@Nonnull @Nonempty final String sRedirectTargetUrl)
  {
    ValueEnforcer.notEmpty (sRedirectTargetUrl, "RedirectTargetUrl");

    if (m_sRedirectTargetUrl != null)
      _info ("Overwriting redirect target URL '" + m_sRedirectTargetUrl + "' with '" + m_sRedirectTargetUrl + "'");
    m_sRedirectTargetUrl = sRedirectTargetUrl;
    return this;
  }

  @Nonnull
  public UnifiedResponse addCookie (@Nonnull final Cookie aCookie)
  {
    ValueEnforcer.notNull (aCookie, "Cookie");

    final String sKey = aCookie.getName ();
    if (m_aCookies == null)
      m_aCookies = new HashMap  ();
    else
      if (m_aCookies.containsKey (sKey))
        _warn ("Overwriting cookie '" + sKey + "' with the new value '" + aCookie.getValue () + "'");
    m_aCookies.put (sKey, aCookie);
    return this;
  }

  @Nonnull
  public UnifiedResponse removeCookie (@Nullable final String sName)
  {
    if (m_aCookies != null)
      m_aCookies.remove (sName);
    return this;
  }

  /**
   * When specifying false, this method uses a special response
   * header to prevent certain browsers from MIME-sniffing a response away from
   * the declared content-type. When passing true, that header is
   * removed.
   * 
   * @param bAllow
   *        Whether or not sniffing should be allowed (default is
   *        true).
   * @return this
   */
  @Nonnull
  public UnifiedResponse setAllowMimeSniffing (final boolean bAllow)
  {
    if (bAllow)
      m_aResponseHeaderMap.removeHeaders (CHTTPHeader.X_CONTENT_TYPE_OPTIONS);
    else
      m_aResponseHeaderMap.addHeader (CHTTPHeader.X_CONTENT_TYPE_OPTIONS, CHTTPHeader.VALUE_NOSNIFF);
    return this;
  }

  /**
   * Adds a response header to the response according to the passed name and
   * value.
* ATTENTION: You should only use the APIs that {@link UnifiedResponse} * directly offers. Use this method only in emergency and make sure you * validate the header field and allowed value! * * @param sName * Name of the header. May neither be null nor empty. * @param sValue * Value of the header. May neither be null nor empty. */ public void addCustomResponseHeader (@Nonnull @Nonempty final String sName, @Nonnull @Nonempty final String sValue) { ValueEnforcer.notEmpty (sName, "Name"); ValueEnforcer.notEmpty (sValue, "Value"); m_aResponseHeaderMap.addHeader (sName, sValue); } /** * Removes the response headers matching the passed name from the response.
* ATTENTION: You should only use the APIs that {@link UnifiedResponse} * directly offers. Use this method only in emergency and make sure you * validate the header field and allowed value! * * @param sName * Name of the header to be removed. May neither be null * nor empty. */ @Nonnull public EChange removeCustomResponseHeaders (@Nonnull @Nonempty final String sName) { ValueEnforcer.notEmpty (sName, "Name"); return m_aResponseHeaderMap.removeHeaders (sName); } /** * When specifying false, this method uses a special response * header to prevent certain browsers from MIME-sniffing a response away from * the declared content-type. When passing true, that header is * removed. * * @param nMaxAgeSeconds * number of seconds, after the reception of the STS header field, * during which the UA regards the host (from whom the message was * received) as a Known HSTS Host. * @param bIncludeSubdomains * if enabled, this signals the UA that the HSTS Policy applies to this * HSTS Host as well as any sub-domains of the host's domain name. * @return this */ @Nonnull public UnifiedResponse setStrictTransportSecurity (final int nMaxAgeSeconds, final boolean bIncludeSubdomains) { m_aResponseHeaderMap.addHeader (CHTTPHeader.STRICT_TRANSPORT_SECURITY, new CacheControlBuilder ().setMaxAgeSeconds (nMaxAgeSeconds) .getAsHTTPHeaderValue () + (bIncludeSubdomains ? ";" + CHTTPHeader.VALUE_INCLUDE_SUBDMOAINS : "")); return this; } private void _verifyCachingIntegrity () { final boolean bIsHttp11 = m_eHTTPVersion == EHTTPVersion.HTTP_11; final boolean bExpires = m_aResponseHeaderMap.containsHeaders (CHTTPHeader.EXPIRES); final boolean bCacheControl = m_aCacheControl != null; final boolean bLastModified = m_aResponseHeaderMap.containsHeaders (CHTTPHeader.LAST_MODIFIED); final boolean bETag = m_aResponseHeaderMap.containsHeaders (CHTTPHeader.ETAG); if (bExpires && bIsHttp11) _info ("Expires found in HTTP 1.1 response: " + m_aResponseHeaderMap.getHeaderValues (CHTTPHeader.EXPIRES)); if (bExpires && bCacheControl) _warn ("Expires and Cache-Control are both present. Cache-Control takes precedence!"); if (bETag && !bIsHttp11) _warn ("Sending an ETag for HTTP version " + m_eHTTPVersion + " has no effect!"); if (!bExpires && !bCacheControl) { if (bLastModified || bETag) _warn ("Validators (Last-Modified and ETag) have no effect if no Expires or Cache-Control is present"); else _warn ("Response has no caching information at all"); } if (m_aCacheControl != null) { if (!bIsHttp11) _warn ("Sending a Cache-Control header for HTTP version " + m_eHTTPVersion + " may have no or limited effect!"); if (m_aCacheControl.isPrivate ()) { if (m_aCacheControl.isPublic ()) _warn ("Cache-Control cannot be private and public at the same time"); if (m_aCacheControl.hasMaxAgeSeconds ()) _warn ("Cache-Control cannot be private and have a max-age definition"); if (m_aCacheControl.hasSharedMaxAgeSeconds ()) _warn ("Cache-Control cannot be private and have a s-maxage definition"); } } } @Nonnull @Nonempty private static String _getAsStringMimeTypes (@Nonnull final Map aMap) { final StringBuilder aSB = new StringBuilder ("{"); for (final Map.Entry aEntry : ContainerHelper.getSortedByValue (aMap).entrySet ()) { if (aSB.length () > 1) aSB.append (", "); aSB.append (aEntry.getKey ().getAsString ()).append ('=').append (aEntry.getValue ().getQuality ()); } return aSB.append ("}").toString (); } @Nonnull @Nonempty private static String _getAsStringText (@Nonnull final Map aMap) { final StringBuilder aSB = new StringBuilder ("{"); for (final Map.Entry aEntry : ContainerHelper.getSortedByValue (aMap).entrySet ()) { if (aSB.length () > 1) aSB.append (", "); aSB.append (aEntry.getKey ()).append ('=').append (aEntry.getValue ().getQuality ()); } return aSB.append ("}").toString (); } public void applyToResponse (@Nonnull final HttpServletResponse aHttpResponse) throws IOException { ValueEnforcer.notNull (aHttpResponse, "HttpResponse"); // Apply all collected headers for (final Map.Entry > aEntry : m_aResponseHeaderMap) { final String sHeaderName = aEntry.getKey (); int nIndex = 0; for (final String sHeaderValue : aEntry.getValue ()) { if (nIndex == 0) aHttpResponse.setHeader (sHeaderName, sHeaderValue); else aHttpResponse.addHeader (sHeaderName, sHeaderValue); ++nIndex; } } final boolean bIsRedirect = m_sRedirectTargetUrl != null; final boolean bHasStatusCode = m_nStatusCode != CGlobal.ILLEGAL_UINT; if (bIsRedirect) { if (bHasStatusCode) _warn ("Ignoring provided status code because a redirect is specified!"); if (!m_bAllowContentOnRedirect) { if (m_aCacheControl != null) _info ("Ignoring provided Cache-Control because a redirect is specified!"); if (m_sContentDispositionFilename != null) _warn ("Ignoring provided Content-Dispostion filename because a redirect is specified!"); if (m_aMimeType != null) _warn ("Ignoring provided MimeType because a redirect is specified!"); if (m_aCharset != null) _warn ("Ignoring provided charset because a redirect is specified!"); if (hasContent ()) _warn ("Ignoring provided content because a redirect is specified!"); } // Note: After using this method, the response should be // considered to be committed and should not be written to. aHttpResponse.sendRedirect (aHttpResponse.encodeRedirectURL (m_sRedirectTargetUrl)); if (!m_bAllowContentOnRedirect) return; } if (bHasStatusCode) { if (bIsRedirect) _warn ("Overriding provided redirect because a status code is specified!"); if (!m_bAllowContentOnStatusCode) { if (m_aCacheControl != null) _info ("Ignoring provided Cache-Control because a status code is specified!"); if (m_sContentDispositionFilename != null) _warn ("Ignoring provided Content-Dispostion filename because a status code is specified!"); if (m_aMimeType != null) _warn ("Ignoring provided MimeType because a status code is specified!"); if (m_aCharset != null) _warn ("Ignoring provided charset because a status code is specified!"); if (hasContent ()) _warn ("Ignoring provided content because a status code is specified!"); } if (m_nStatusCode == HttpServletResponse.SC_UNAUTHORIZED && !m_aResponseHeaderMap.containsHeaders (CHTTPHeader.WWW_AUTHENTICATE)) _warn ("Status code UNAUTHORIZED (401) is returned, but no " + CHTTPHeader.WWW_AUTHENTICATE + " HTTP response header is set!"); // Content may be present so, sendError is not an option here! if (m_nStatusCode >= HttpServletResponse.SC_BAD_REQUEST && m_aContent == null) { // It's an error // Note: After using this method, the response should be considered // to be committed and should not be written to. aHttpResponse.sendError (m_nStatusCode); } else { // It's a status message "only" // Note: The container clears the buffer and sets the Location // header, preserving cookies and other headers. aHttpResponse.setStatus (m_nStatusCode); } if (!m_bAllowContentOnStatusCode) return; } // Verify only if is a response with content _verifyCachingIntegrity (); if (m_aCacheControl != null) { final String sCacheControlValue = m_aCacheControl.getAsHTTPHeaderValue (); if (StringHelper.hasText (sCacheControlValue)) aHttpResponse.setHeader (CHTTPHeader.CACHE_CONTROL, sCacheControlValue); else _warn ("An empty Cache-Control was provided!"); } if (m_sContentDispositionFilename != null) { final StringBuilder aSB = new StringBuilder (); if (m_aRequestBrowserInfo != null && m_aRequestBrowserInfo.getBrowserType () == EBrowserType.IE && m_aRequestBrowserInfo.getVersion ().getMajor () <= 8) { // Special case for IE <= 8 final Charset aCharsetToUse = m_aCharset != null ? m_aCharset : CCharset.CHARSET_UTF_8_OBJ; aSB.append (m_eContentDispositionType.getID ()) .append ("; filename=") .append (URLUtils.urlEncode (m_sContentDispositionFilename, aCharsetToUse)); } else { // Filename needs to be surrounded with double quotes (single quotes // don't work). aSB.append (m_eContentDispositionType.getID ()) .append ("; filename=\"") .append (m_sContentDispositionFilename) .append ("\""); // Check if we need an UTF-8 filename // http://stackoverflow.com/questions/93551/how-to-encode-the-filename-parameter-of-content-disposition-header-in-http/6745788#6745788 final String sRFC5987Filename = RFC5987Encoder.getRFC5987EncodedUTF8 (m_sContentDispositionFilename); if (!sRFC5987Filename.equals (m_sContentDispositionFilename)) aSB.append ("; filename*=UTF-8''").append (sRFC5987Filename); } aHttpResponse.setHeader (CHTTPHeader.CONTENT_DISPOSITION, aSB.toString ()); if (m_aMimeType == null) { _warn ("Content-Disposition is specified but no MimeType is set. Using the default download MimeType."); aHttpResponse.setContentType (CMimeType.APPLICATION_FORCE_DOWNLOAD.getAsString ()); } } // Mime type if (m_aMimeType != null) { final String sMimeType = m_aMimeType.getAsString (); // Check with request accept mime types final QValue aQuality = m_aAcceptMimeTypeList.getQValueOfMimeType (m_aMimeType); if (aQuality.isMinimumQuality ()) { final Map aBetterValues = m_aAcceptMimeTypeList.getAllQValuesGreaterThan (aQuality.getQuality ()); _error ("MimeType '" + sMimeType + "' is not at all supported by the request. Allowed values are: " + _getAsStringMimeTypes (aBetterValues)); } else if (aQuality.isLowValue ()) { // This might bloat the logfile for text/css MIME types and therefore // only in the debug version if (GlobalDebug.isDebugMode ()) { // Inform if the quality of the request is <= 50%! final Map aBetterValues = m_aAcceptMimeTypeList.getAllQValuesGreaterThan (aQuality.getQuality ()); if (!aBetterValues.isEmpty ()) _warn ("MimeType '" + sMimeType + "' is not best supported by the request (" + aQuality + "). Better MimeTypes are: " + _getAsStringMimeTypes (aBetterValues)); } } aHttpResponse.setContentType (sMimeType); } else _warn ("No MimeType present"); // Charset if (m_aCharset != null) { final String sCharset = m_aCharset.name (); if (m_aMimeType == null) _warn ("If no MimeType present, the client cannot get notified about the character encoding '" + sCharset + "'"); // Check with request charset final QValue aQuality = m_aAcceptCharsetList.getQValueOfCharset (sCharset); if (aQuality.isMinimumQuality ()) { final Map aBetterValues = m_aAcceptCharsetList.getAllQValuesGreaterThan (aQuality.getQuality ()); _error ("Character encoding '" + sCharset + "' is not at all supported by the request. Allowed values are: " + _getAsStringText (aBetterValues)); } else if (aQuality.isLowValue ()) { // Inform if the quality of the request is <= 50%! final Map aBetterValues = m_aAcceptCharsetList.getAllQValuesGreaterThan (aQuality.getQuality ()); if (!aBetterValues.isEmpty ()) _warn ("Character encoding '" + sCharset + "' is not best supported by the request (" + aQuality + "). Better charsets are: " + _getAsStringText (aBetterValues)); } aHttpResponse.setCharacterEncoding (sCharset); } else if (m_aMimeType == null) _warn ("Also no character encoding present"); else switch (m_aMimeType.getContentType ()) { case TEXT: case MULTIPART: _warn ("A character encoding for MimeType '" + m_aMimeType.getAsString () + "' is appreciated."); break; default: // Do we need character encoding here as well??? break; } // Add all cookies if (m_aCookies != null) for (final Cookie aCookie : m_aCookies.values ()) aHttpResponse.addCookie (aCookie); // Write the body to the response _applyContent (aHttpResponse); } @Nonnull protected HTTPHeaderMap getResponseHeaderMap () { return m_aResponseHeaderMap; } private void _applyLengthChecks (final long nContentLength) { // Source: // http://joshua.perina.com/africa/gambia/fajara/post/internet-explorer-css-file-size-limit if (m_aMimeType != null && m_aMimeType.equals (CMimeType.TEXT_CSS) && nContentLength > (MAX_CSS_KB_FOR_IE * CGlobal.BYTES_PER_KILOBYTE_LONG)) { _warn ("Internet Explorer has problems handling CSS files > " + MAX_CSS_KB_FOR_IE + "KB and this one has " + nContentLength + " bytes!"); } } private void _applyContent (@Nonnull final HttpServletResponse aHttpResponse) throws IOException { if (m_aContent != null) { // We're having a fixed byte array of content final int nContentLength = m_aContent.length; // Determine the response stream type to use final EResponseStreamType eResponseStreamType = ResponseHelper.getBestSuitableOutputStreamType (m_aHttpRequest); if (eResponseStreamType.isUncompressed ()) { // Must be set before the content itself arrives // Note: Set it only if the content is uncompressed, because we cannot // determine the length of the compressed text in advance without // computational overhead ResponseHelper.setContentLength (aHttpResponse, nContentLength); } // Don't emit empty content or content for HEAD method if (nContentLength > 0 && m_eHTTPMethod.isContentAllowed ()) { // Create the correct stream final OutputStream aOS = ResponseHelper.getBestSuitableOutputStream (m_aHttpRequest, aHttpResponse); // Emit main content to stream aOS.write (m_aContent, 0, nContentLength); aOS.flush (); aOS.close (); _applyLengthChecks (nContentLength); } // Don't send 204, as this is most likely not handled correctly on the // client side } else if (m_aContentISP != null) { // We have a dynamic content input stream // -> no content length can be determined! final InputStream aContentIS = m_aContentISP.getInputStream (); if (aContentIS == null) { s_aLogger.error ("Failed to open input stream from " + m_aContentISP); // Handle it gracefully with a 404 and not with a 500 aHttpResponse.setStatus (HttpServletResponse.SC_NOT_FOUND); } else { // Don't emit content for HEAD method if (m_eHTTPMethod.isContentAllowed ()) { // We do have an input stream // -> copy it to the response final OutputStream aOS = aHttpResponse.getOutputStream (); final MutableLong aByteCount = new MutableLong (); if (StreamUtils.copyInputStreamToOutputStream (aContentIS, aOS, aByteCount).isSuccess ()) { // Copying succeeded final long nBytesCopied = aByteCount.longValue (); // Don't apply additional Content-Length header after the resource // was streamed! _applyLengthChecks (nBytesCopied); } else { // Copying failed -> this is a 500 final boolean bResponseCommitted = aHttpResponse.isCommitted (); _error ("Copying from " + m_aContentISP + " failed after " + aByteCount.longValue () + " bytes! Response is committed: " + bResponseCommitted); if (!bResponseCommitted) aHttpResponse.sendError (HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } } } } else { // Set status 204 - no content; this is most likely a programming // error aHttpResponse.setStatus (HttpServletResponse.SC_NO_CONTENT); _warn ("No content present for the response"); } } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy