001/*
002 * Copyright 2002-2018 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.web.filter;
018
019import java.io.IOException;
020import java.io.InputStream;
021import java.io.PrintWriter;
022import javax.servlet.FilterChain;
023import javax.servlet.ServletException;
024import javax.servlet.ServletOutputStream;
025import javax.servlet.ServletRequest;
026import javax.servlet.http.HttpServletRequest;
027import javax.servlet.http.HttpServletResponse;
028
029import org.springframework.http.HttpMethod;
030import org.springframework.util.Assert;
031import org.springframework.util.ClassUtils;
032import org.springframework.util.DigestUtils;
033import org.springframework.web.util.ContentCachingResponseWrapper;
034import org.springframework.web.util.WebUtils;
035
036/**
037 * {@link javax.servlet.Filter} that generates an {@code ETag} value based on the
038 * content on the response. This ETag is compared to the {@code If-None-Match}
039 * header of the request. If these headers are equal, the response content is
040 * not sent, but rather a {@code 304 "Not Modified"} status instead.
041 *
042 * <p>Since the ETag is based on the response content, the response
043 * (e.g. a {@link org.springframework.web.servlet.View}) is still rendered.
044 * As such, this filter only saves bandwidth, not server performance.
045 *
046 * @author Arjen Poutsma
047 * @author Rossen Stoyanchev
048 * @author Brian Clozel
049 * @author Juergen Hoeller
050 * @since 3.0
051 */
052public class ShallowEtagHeaderFilter extends OncePerRequestFilter {
053
054        private static final String HEADER_ETAG = "ETag";
055
056        private static final String HEADER_IF_NONE_MATCH = "If-None-Match";
057
058        private static final String HEADER_CACHE_CONTROL = "Cache-Control";
059
060        private static final String DIRECTIVE_NO_STORE = "no-store";
061
062        private static final String STREAMING_ATTRIBUTE = ShallowEtagHeaderFilter.class.getName() + ".STREAMING";
063
064
065        /** Checking for Servlet 3.0+ HttpServletResponse.getHeader(String) */
066        private static final boolean servlet3Present =
067                        ClassUtils.hasMethod(HttpServletResponse.class, "getHeader", String.class);
068
069        private boolean writeWeakETag = false;
070
071
072        /**
073         * Set whether the ETag value written to the response should be weak, as per RFC 7232.
074         * <p>Should be configured using an {@code <init-param>} for parameter name
075         * "writeWeakETag" in the filter definition in {@code web.xml}.
076         * @since 4.3
077         * @see <a href="https://tools.ietf.org/html/rfc7232#section-2.3">RFC 7232 section 2.3</a>
078         */
079        public void setWriteWeakETag(boolean writeWeakETag) {
080                this.writeWeakETag = writeWeakETag;
081        }
082
083        /**
084         * Return whether the ETag value written to the response should be weak, as per RFC 7232.
085         * @since 4.3
086         */
087        public boolean isWriteWeakETag() {
088                return this.writeWeakETag;
089        }
090
091
092        /**
093         * The default value is {@code false} so that the filter may delay the generation
094         * of an ETag until the last asynchronously dispatched thread.
095         */
096        @Override
097        protected boolean shouldNotFilterAsyncDispatch() {
098                return false;
099        }
100
101        @Override
102        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
103                        throws ServletException, IOException {
104
105                HttpServletResponse responseToUse = response;
106                if (!isAsyncDispatch(request) && !(response instanceof ContentCachingResponseWrapper)) {
107                        responseToUse = new HttpStreamingAwareContentCachingResponseWrapper(response, request);
108                }
109
110                filterChain.doFilter(request, responseToUse);
111
112                if (!isAsyncStarted(request) && !isContentCachingDisabled(request)) {
113                        updateResponse(request, responseToUse);
114                }
115        }
116
117        private void updateResponse(HttpServletRequest request, HttpServletResponse response) throws IOException {
118                ContentCachingResponseWrapper responseWrapper =
119                                WebUtils.getNativeResponse(response, ContentCachingResponseWrapper.class);
120                Assert.notNull(responseWrapper, "ContentCachingResponseWrapper not found");
121                HttpServletResponse rawResponse = (HttpServletResponse) responseWrapper.getResponse();
122                int statusCode = responseWrapper.getStatusCode();
123
124                if (rawResponse.isCommitted()) {
125                        responseWrapper.copyBodyToResponse();
126                }
127                else if (isEligibleForEtag(request, responseWrapper, statusCode, responseWrapper.getContentInputStream())) {
128                        String responseETag = generateETagHeaderValue(responseWrapper.getContentInputStream(), this.writeWeakETag);
129                        rawResponse.setHeader(HEADER_ETAG, responseETag);
130                        String requestETag = request.getHeader(HEADER_IF_NONE_MATCH);
131                        if (requestETag != null && ("*".equals(requestETag) || responseETag.equals(requestETag) ||
132                                        responseETag.replaceFirst("^W/", "").equals(requestETag.replaceFirst("^W/", "")))) {
133                                if (logger.isTraceEnabled()) {
134                                        logger.trace("ETag [" + responseETag + "] equal to If-None-Match, sending 304");
135                                }
136                                rawResponse.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
137                        }
138                        else {
139                                if (logger.isTraceEnabled()) {
140                                        logger.trace("ETag [" + responseETag + "] not equal to If-None-Match [" + requestETag +
141                                                        "], sending normal response");
142                                }
143                                responseWrapper.copyBodyToResponse();
144                        }
145                }
146                else {
147                        if (logger.isTraceEnabled()) {
148                                logger.trace("Response with status code [" + statusCode + "] not eligible for ETag");
149                        }
150                        responseWrapper.copyBodyToResponse();
151                }
152        }
153
154        /**
155         * Indicates whether the given request and response are eligible for ETag generation.
156         * <p>The default implementation returns {@code true} if all conditions match:
157         * <ul>
158         * <li>response status codes in the {@code 2xx} series</li>
159         * <li>request method is a GET</li>
160         * <li>response Cache-Control header is not set or does not contain a "no-store" directive</li>
161         * </ul>
162         * @param request the HTTP request
163         * @param response the HTTP response
164         * @param responseStatusCode the HTTP response status code
165         * @param inputStream the response body
166         * @return {@code true} if eligible for ETag generation, {@code false} otherwise
167         */
168        protected boolean isEligibleForEtag(HttpServletRequest request, HttpServletResponse response,
169                        int responseStatusCode, InputStream inputStream) {
170
171                String method = request.getMethod();
172                if (responseStatusCode >= 200 && responseStatusCode < 300 && HttpMethod.GET.matches(method)) {
173                        String cacheControl = null;
174                        if (servlet3Present) {
175                                cacheControl = response.getHeader(HEADER_CACHE_CONTROL);
176                        }
177                        if (cacheControl == null || !cacheControl.contains(DIRECTIVE_NO_STORE)) {
178                                return true;
179                        }
180                }
181                return false;
182        }
183
184        /**
185         * Generate the ETag header value from the given response body byte array.
186         * <p>The default implementation generates an MD5 hash.
187         * @param inputStream the response body as an InputStream
188         * @param isWeak whether the generated ETag should be weak
189         * @return the ETag header value
190         * @see org.springframework.util.DigestUtils
191         */
192        protected String generateETagHeaderValue(InputStream inputStream, boolean isWeak) throws IOException {
193                // length of W/ + " + 0 + 32bits md5 hash + "
194                StringBuilder builder = new StringBuilder(37);
195                if (isWeak) {
196                        builder.append("W/");
197                }
198                builder.append("\"0");
199                DigestUtils.appendMd5DigestAsHex(inputStream, builder);
200                builder.append('"');
201                return builder.toString();
202        }
203
204
205        /**
206         * This method can be used to disable the content caching response wrapper
207         * of the ShallowEtagHeaderFilter. This can be done before the start of HTTP
208         * streaming for example where the response will be written to asynchronously
209         * and not in the context of a Servlet container thread.
210         * @since 4.2
211         */
212        public static void disableContentCaching(ServletRequest request) {
213                Assert.notNull(request, "ServletRequest must not be null");
214                request.setAttribute(STREAMING_ATTRIBUTE, true);
215        }
216
217        private static boolean isContentCachingDisabled(HttpServletRequest request) {
218                return (request.getAttribute(STREAMING_ATTRIBUTE) != null);
219        }
220
221
222        private static class HttpStreamingAwareContentCachingResponseWrapper extends ContentCachingResponseWrapper {
223
224                private final HttpServletRequest request;
225
226                public HttpStreamingAwareContentCachingResponseWrapper(HttpServletResponse response, HttpServletRequest request) {
227                        super(response);
228                        this.request = request;
229                }
230
231                @Override
232                public ServletOutputStream getOutputStream() throws IOException {
233                        return (useRawResponse() ? getResponse().getOutputStream() : super.getOutputStream());
234                }
235
236                @Override
237                public PrintWriter getWriter() throws IOException {
238                        return (useRawResponse() ? getResponse().getWriter() : super.getWriter());
239                }
240
241                private boolean useRawResponse() {
242                        return isContentCachingDisabled(this.request);
243                }
244        }
245
246}