001/*
002 * Copyright 2002-2020 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;
022
023import javax.servlet.FilterChain;
024import javax.servlet.ServletException;
025import javax.servlet.ServletOutputStream;
026import javax.servlet.ServletRequest;
027import javax.servlet.http.HttpServletRequest;
028import javax.servlet.http.HttpServletResponse;
029
030import org.springframework.http.HttpHeaders;
031import org.springframework.http.HttpMethod;
032import org.springframework.util.Assert;
033import org.springframework.util.DigestUtils;
034import org.springframework.util.StringUtils;
035import org.springframework.web.context.request.ServletWebRequest;
036import org.springframework.web.util.ContentCachingResponseWrapper;
037import org.springframework.web.util.WebUtils;
038
039/**
040 * {@link javax.servlet.Filter} that generates an {@code ETag} value based on the
041 * content on the response. This ETag is compared to the {@code If-None-Match}
042 * header of the request. If these headers are equal, the response content is
043 * not sent, but rather a {@code 304 "Not Modified"} status instead.
044 *
045 * <p>Since the ETag is based on the response content, the response
046 * (e.g. a {@link org.springframework.web.servlet.View}) is still rendered.
047 * As such, this filter only saves bandwidth, not server performance.
048 *
049 * <p><b>NOTE:</b> As of Spring Framework 5.0, this filter uses request/response
050 * decorators built on the Servlet 3.1 API.
051 *
052 * @author Arjen Poutsma
053 * @author Rossen Stoyanchev
054 * @author Brian Clozel
055 * @author Juergen Hoeller
056 * @since 3.0
057 */
058public class ShallowEtagHeaderFilter extends OncePerRequestFilter {
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        private boolean writeWeakETag = false;
066
067
068        /**
069         * Set whether the ETag value written to the response should be weak, as per RFC 7232.
070         * <p>Should be configured using an {@code <init-param>} for parameter name
071         * "writeWeakETag" in the filter definition in {@code web.xml}.
072         * @since 4.3
073         * @see <a href="https://tools.ietf.org/html/rfc7232#section-2.3">RFC 7232 section 2.3</a>
074         */
075        public void setWriteWeakETag(boolean writeWeakETag) {
076                this.writeWeakETag = writeWeakETag;
077        }
078
079        /**
080         * Return whether the ETag value written to the response should be weak, as per RFC 7232.
081         * @since 4.3
082         */
083        public boolean isWriteWeakETag() {
084                return this.writeWeakETag;
085        }
086
087
088        /**
089         * The default value is {@code false} so that the filter may delay the generation
090         * of an ETag until the last asynchronously dispatched thread.
091         */
092        @Override
093        protected boolean shouldNotFilterAsyncDispatch() {
094                return false;
095        }
096
097        @Override
098        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
099                        throws ServletException, IOException {
100
101                HttpServletResponse responseToUse = response;
102                if (!isAsyncDispatch(request) && !(response instanceof ConditionalContentCachingResponseWrapper)) {
103                        responseToUse = new ConditionalContentCachingResponseWrapper(response, request);
104                }
105
106                filterChain.doFilter(request, responseToUse);
107
108                if (!isAsyncStarted(request) && !isContentCachingDisabled(request)) {
109                        updateResponse(request, responseToUse);
110                }
111        }
112
113        private void updateResponse(HttpServletRequest request, HttpServletResponse response) throws IOException {
114                ConditionalContentCachingResponseWrapper wrapper =
115                                WebUtils.getNativeResponse(response, ConditionalContentCachingResponseWrapper.class);
116                Assert.notNull(wrapper, "ContentCachingResponseWrapper not found");
117                HttpServletResponse rawResponse = (HttpServletResponse) wrapper.getResponse();
118
119                if (isEligibleForEtag(request, wrapper, wrapper.getStatus(), wrapper.getContentInputStream())) {
120                        String eTag = wrapper.getHeader(HttpHeaders.ETAG);
121                        if (!StringUtils.hasText(eTag)) {
122                                eTag = generateETagHeaderValue(wrapper.getContentInputStream(), this.writeWeakETag);
123                                rawResponse.setHeader(HttpHeaders.ETAG, eTag);
124                        }
125                        if (new ServletWebRequest(request, rawResponse).checkNotModified(eTag)) {
126                                return;
127                        }
128                }
129
130                wrapper.copyBodyToResponse();
131        }
132
133        /**
134         * Whether an ETag should be calculated for the given request and response
135         * exchange. By default this is {@code true} if all of the following match:
136         * <ul>
137         * <li>Response is not committed.</li>
138         * <li>Response status codes is in the {@code 2xx} series.</li>
139         * <li>Request method is a GET.</li>
140         * <li>Response Cache-Control header does not contain "no-store" (or is not present at all).</li>
141         * </ul>
142         * @param request the HTTP request
143         * @param response the HTTP response
144         * @param responseStatusCode the HTTP response status code
145         * @param inputStream the response body
146         * @return {@code true} if eligible for ETag generation, {@code false} otherwise
147         */
148        protected boolean isEligibleForEtag(HttpServletRequest request, HttpServletResponse response,
149                        int responseStatusCode, InputStream inputStream) {
150
151                if (!response.isCommitted() &&
152                                responseStatusCode >= 200 && responseStatusCode < 300 &&
153                                HttpMethod.GET.matches(request.getMethod())) {
154
155                        String cacheControl = response.getHeader(HttpHeaders.CACHE_CONTROL);
156                        return (cacheControl == null || !cacheControl.contains(DIRECTIVE_NO_STORE));
157                }
158
159                return false;
160        }
161
162        /**
163         * Generate the ETag header value from the given response body byte array.
164         * <p>The default implementation generates an MD5 hash.
165         * @param inputStream the response body as an InputStream
166         * @param isWeak whether the generated ETag should be weak
167         * @return the ETag header value
168         * @see org.springframework.util.DigestUtils
169         */
170        protected String generateETagHeaderValue(InputStream inputStream, boolean isWeak) throws IOException {
171                // length of W/ + " + 0 + 32bits md5 hash + "
172                StringBuilder builder = new StringBuilder(37);
173                if (isWeak) {
174                        builder.append("W/");
175                }
176                builder.append("\"0");
177                DigestUtils.appendMd5DigestAsHex(inputStream, builder);
178                builder.append('"');
179                return builder.toString();
180        }
181
182        private boolean compareETagHeaderValue(String requestETag, String responseETag) {
183                if (requestETag.startsWith("W/")) {
184                        requestETag = requestETag.substring(2);
185                }
186                if (responseETag.startsWith("W/")) {
187                        responseETag = responseETag.substring(2);
188                }
189                return requestETag.equals(responseETag);
190        }
191
192
193        /**
194         * This method can be used to suppress the content caching response wrapper
195         * of the ShallowEtagHeaderFilter. The main reason for this is streaming
196         * scenarios which are not to be cached and do not need an eTag.
197         * <p><strong>Note:</strong> This method must be called before the response
198         * is written to in order for the entire response content to be written
199         * without caching.
200         * @since 4.2
201         */
202        public static void disableContentCaching(ServletRequest request) {
203                Assert.notNull(request, "ServletRequest must not be null");
204                request.setAttribute(STREAMING_ATTRIBUTE, true);
205        }
206
207        private static boolean isContentCachingDisabled(HttpServletRequest request) {
208                return (request.getAttribute(STREAMING_ATTRIBUTE) != null);
209        }
210
211
212        /**
213         * Returns the raw OutputStream, instead of the one that does caching,
214         * if {@link #isContentCachingDisabled}.
215         */
216        private static class ConditionalContentCachingResponseWrapper extends ContentCachingResponseWrapper {
217
218                private final HttpServletRequest request;
219
220                ConditionalContentCachingResponseWrapper(HttpServletResponse response, HttpServletRequest request) {
221                        super(response);
222                        this.request = request;
223                }
224
225                @Override
226                public ServletOutputStream getOutputStream() throws IOException {
227                        return (isContentCachingDisabled(this.request) || hasETag() ?
228                                        getResponse().getOutputStream() : super.getOutputStream());
229                }
230
231                @Override
232                public PrintWriter getWriter() throws IOException {
233                        return (isContentCachingDisabled(this.request) || hasETag()?
234                                        getResponse().getWriter() : super.getWriter());
235                }
236
237                private boolean hasETag() {
238                        return StringUtils.hasText(getHeader(HttpHeaders.ETAG));
239                }
240        }
241
242}