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.reactive.result.view;
018
019import java.net.URI;
020import java.nio.charset.StandardCharsets;
021import java.util.Collections;
022import java.util.Locale;
023import java.util.Map;
024import java.util.regex.Matcher;
025import java.util.regex.Pattern;
026
027import reactor.core.publisher.Mono;
028
029import org.springframework.http.HttpStatus;
030import org.springframework.http.MediaType;
031import org.springframework.http.server.reactive.ServerHttpRequest;
032import org.springframework.http.server.reactive.ServerHttpResponse;
033import org.springframework.lang.Nullable;
034import org.springframework.util.Assert;
035import org.springframework.util.ObjectUtils;
036import org.springframework.util.StringUtils;
037import org.springframework.web.reactive.HandlerMapping;
038import org.springframework.web.server.ServerWebExchange;
039import org.springframework.web.util.UriComponentsBuilder;
040import org.springframework.web.util.UriUtils;
041
042/**
043 * View that redirects to an absolute or context relative URL. The URL may be a
044 * URI template in which case the URI template variables will be replaced with
045 * values from the model or with URI variables from the current request.
046 *
047 * <p>By default {@link HttpStatus#SEE_OTHER} is used but alternate status codes
048 * may be via constructor or setters arguments.
049 *
050 * @author Sebastien Deleuze
051 * @author Rossen Stoyanchev
052 * @since 5.0
053 */
054public class RedirectView extends AbstractUrlBasedView {
055
056        private static final Pattern URI_TEMPLATE_VARIABLE_PATTERN = Pattern.compile("\\{([^/]+?)}");
057
058
059        private HttpStatus statusCode = HttpStatus.SEE_OTHER;
060
061        private boolean contextRelative = true;
062
063        private boolean propagateQuery = false;
064
065        @Nullable
066        private String[] hosts;
067
068
069        /**
070         * Constructor for use as a bean.
071         */
072        public RedirectView() {
073        }
074
075        /**
076         * Create a new {@code RedirectView} with the given redirect URL.
077         * Status code {@link HttpStatus#SEE_OTHER} is used by default.
078         */
079        public RedirectView(String redirectUrl) {
080                super(redirectUrl);
081        }
082
083        /**
084         * Create a new {@code RedirectView} with the given URL and an alternate
085         * redirect status code such as {@link HttpStatus#TEMPORARY_REDIRECT} or
086         * {@link HttpStatus#PERMANENT_REDIRECT}.
087         */
088        public RedirectView(String redirectUrl, HttpStatus statusCode) {
089                super(redirectUrl);
090                setStatusCode(statusCode);
091        }
092
093
094        /**
095         * Set an alternate redirect status code such as
096         * {@link HttpStatus#TEMPORARY_REDIRECT} or
097         * {@link HttpStatus#PERMANENT_REDIRECT}.
098         */
099        public void setStatusCode(HttpStatus statusCode) {
100                Assert.isTrue(statusCode.is3xxRedirection(), "Not a redirect status code");
101                this.statusCode = statusCode;
102        }
103
104        /**
105         * Get the redirect status code to use.
106         */
107        public HttpStatus getStatusCode() {
108                return this.statusCode;
109        }
110
111        /**
112         * Whether to interpret a given redirect URLs that starts with a slash ("/")
113         * as relative to the current context path ({@code true}, the default) or to
114         * the web server root ({@code false}).
115         */
116        public void setContextRelative(boolean contextRelative) {
117                this.contextRelative = contextRelative;
118        }
119
120        /**
121         * Whether to interpret URLs as relative to the current context path.
122         */
123        public boolean isContextRelative() {
124                return this.contextRelative;
125        }
126
127        /**
128         * Whether to append the query string of the current URL to the redirect URL
129         * ({@code true}) or not ({@code false}, the default).
130         */
131        public void setPropagateQuery(boolean propagateQuery) {
132                this.propagateQuery = propagateQuery;
133        }
134
135        /**
136         * Whether the query string of the current URL is appended to the redirect URL.
137         */
138        public boolean isPropagateQuery() {
139                return this.propagateQuery;
140        }
141
142        /**
143         * Configure one or more hosts associated with the application.
144         * All other hosts will be considered external hosts.
145         * <p>In effect this provides a way turn off encoding for URLs that
146         * have a host and that host is not listed as a known host.
147         * <p>If not set (the default) all redirect URLs are encoded.
148         * @param hosts one or more application hosts
149         */
150        public void setHosts(@Nullable String... hosts) {
151                this.hosts = hosts;
152        }
153
154        /**
155         * Return the configured application hosts.
156         */
157        @Nullable
158        public String[] getHosts() {
159                return this.hosts;
160        }
161
162
163        @Override
164        public void afterPropertiesSet() throws Exception {
165                super.afterPropertiesSet();
166        }
167
168
169        @Override
170        public boolean isRedirectView() {
171                return true;
172        }
173
174        @Override
175        public boolean checkResourceExists(Locale locale) throws Exception {
176                return true;
177        }
178
179        /**
180         * Convert model to request parameters and redirect to the given URL.
181         */
182        @Override
183        protected Mono<Void> renderInternal(
184                        Map<String, Object> model, @Nullable MediaType contentType, ServerWebExchange exchange) {
185
186                String targetUrl = createTargetUrl(model, exchange);
187                return sendRedirect(targetUrl, exchange);
188        }
189
190        /**
191         * Create the target URL and, if necessary, pre-pend the contextPath, expand
192         * URI template variables, append the current request query, and apply the
193         * configured {@link #getRequestDataValueProcessor()
194         * RequestDataValueProcessor}.
195         */
196        protected final String createTargetUrl(Map<String, Object> model, ServerWebExchange exchange) {
197                String url = getUrl();
198                Assert.state(url != null, "'url' not set");
199
200                ServerHttpRequest request = exchange.getRequest();
201
202                StringBuilder targetUrl = new StringBuilder();
203                if (isContextRelative() && url.startsWith("/")) {
204                        targetUrl.append(request.getPath().contextPath().value());
205                }
206                targetUrl.append(url);
207
208                if (StringUtils.hasText(targetUrl)) {
209                        Map<String, String> uriVars = getCurrentUriVariables(exchange);
210                        targetUrl = expandTargetUrlTemplate(targetUrl.toString(), model, uriVars);
211                }
212
213                if (isPropagateQuery()) {
214                        targetUrl = appendCurrentRequestQuery(targetUrl.toString(), request);
215                }
216
217                String result = targetUrl.toString();
218
219                RequestDataValueProcessor processor = getRequestDataValueProcessor();
220                return (processor != null ? processor.processUrl(exchange, result) : result);
221        }
222
223        private Map<String, String> getCurrentUriVariables(ServerWebExchange exchange) {
224                String name = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE;
225                return exchange.getAttributeOrDefault(name, Collections.emptyMap());
226        }
227
228        /**
229         * Expand URI template variables in the target URL with either model
230         * attribute values or as a fallback with URI variable values from the
231         * current request. Values are encoded.
232         */
233        protected StringBuilder expandTargetUrlTemplate(String targetUrl,
234                        Map<String, Object> model, Map<String, String> uriVariables) {
235
236                Matcher matcher = URI_TEMPLATE_VARIABLE_PATTERN.matcher(targetUrl);
237                boolean found = matcher.find();
238                if (!found) {
239                        return new StringBuilder(targetUrl);
240                }
241                StringBuilder result = new StringBuilder();
242                int endLastMatch = 0;
243                while (found) {
244                        String name = matcher.group(1);
245                        Object value = (model.containsKey(name) ? model.get(name) : uriVariables.get(name));
246                        Assert.notNull(value, () -> "No value for URI variable '" + name + "'");
247                        result.append(targetUrl, endLastMatch, matcher.start());
248                        result.append(encodeUriVariable(value.toString()));
249                        endLastMatch = matcher.end();
250                        found = matcher.find();
251                }
252                result.append(targetUrl, endLastMatch, targetUrl.length());
253                return result;
254        }
255
256        private String encodeUriVariable(String text) {
257                // Strict encoding of all reserved URI characters
258                return UriUtils.encode(text, StandardCharsets.UTF_8);
259        }
260
261        /**
262         * Append the query of the current request to the target redirect URL.
263         */
264        protected StringBuilder appendCurrentRequestQuery(String targetUrl, ServerHttpRequest request) {
265                String query = request.getURI().getRawQuery();
266                if (!StringUtils.hasText(query)) {
267                        return new StringBuilder(targetUrl);
268                }
269
270                int index = targetUrl.indexOf('#');
271                String fragment = (index > -1 ? targetUrl.substring(index) : null);
272
273                StringBuilder result = new StringBuilder();
274                result.append(index != -1 ? targetUrl.substring(0, index) : targetUrl);
275                result.append(targetUrl.indexOf('?') < 0 ? '?' : '&').append(query);
276
277                if (fragment != null) {
278                        result.append(fragment);
279                }
280
281                return result;
282        }
283
284        /**
285         * Send a redirect back to the HTTP client.
286         * @param targetUrl the target URL to redirect to
287         * @param exchange current exchange
288         */
289        protected Mono<Void> sendRedirect(String targetUrl, ServerWebExchange exchange) {
290                String transformedUrl = (isRemoteHost(targetUrl) ? targetUrl : exchange.transformUrl(targetUrl));
291                ServerHttpResponse response = exchange.getResponse();
292                response.getHeaders().setLocation(URI.create(transformedUrl));
293                response.setStatusCode(getStatusCode());
294                return Mono.empty();
295        }
296
297        /**
298         * Whether the given targetUrl has a host that is a "foreign" system in which
299         * case {@link javax.servlet.http.HttpServletResponse#encodeRedirectURL} will not be applied.
300         * This method returns {@code true} if the {@link #setHosts(String[])}
301         * property is configured and the target URL has a host that does not match.
302         * @param targetUrl the target redirect URL
303         * @return {@code true} the target URL has a remote host, {@code false} if it
304         * the URL does not have a host or the "host" property is not configured.
305         */
306        protected boolean isRemoteHost(String targetUrl) {
307                if (ObjectUtils.isEmpty(this.hosts)) {
308                        return false;
309                }
310                String targetHost = UriComponentsBuilder.fromUriString(targetUrl).build().getHost();
311                if (!StringUtils.hasLength(targetHost)) {
312                        return false;
313                }
314                for (String host : this.hosts) {
315                        if (targetHost.equals(host)) {
316                                return false;
317                        }
318                }
319                return true;
320        }
321
322}