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.servlet.tags;
018
019import java.io.IOException;
020import java.nio.charset.UnsupportedCharsetException;
021import java.util.Collections;
022import java.util.HashSet;
023import java.util.LinkedList;
024import java.util.List;
025import java.util.Set;
026
027import javax.servlet.ServletRequest;
028import javax.servlet.http.HttpServletRequest;
029import javax.servlet.http.HttpServletResponse;
030import javax.servlet.jsp.JspException;
031import javax.servlet.jsp.PageContext;
032
033import org.springframework.lang.Nullable;
034import org.springframework.util.Assert;
035import org.springframework.util.StringUtils;
036import org.springframework.web.servlet.support.RequestDataValueProcessor;
037import org.springframework.web.util.JavaScriptUtils;
038import org.springframework.web.util.TagUtils;
039import org.springframework.web.util.UriUtils;
040
041/**
042 * The {@code <url>} tag creates URLs. Modeled after the JSTL {@code c:url} tag with
043 * backwards compatibility in mind.
044 *
045 * <p>Enhancements to the JSTL functionality include:
046 * <ul>
047 * <li>URL encoded template URI variables</li>
048 * <li>HTML/XML escaping of URLs</li>
049 * <li>JavaScript escaping of URLs</li>
050 * </ul>
051 *
052 * <p>Template URI variables are indicated in the {@link #setValue(String) 'value'}
053 * attribute and marked by braces '{variableName}'. The braces and attribute name are
054 * replaced by the URL encoded value of a parameter defined with the spring:param tag
055 * in the body of the url tag. If no parameter is available the literal value is
056 * passed through. Params matched to template variables will not be added to the query
057 * string.
058 *
059 * <p>Use of the spring:param tag for URI template variables is strongly recommended
060 * over direct EL substitution as the values are URL encoded.  Failure to properly
061 * encode URL can leave an application vulnerable to XSS and other injection attacks.
062 *
063 * <p>URLs can be HTML/XML escaped by setting the {@link #setHtmlEscape(boolean)
064 * 'htmlEscape'} attribute to 'true'.  Detects an HTML escaping setting, either on
065 * this tag instance, the page level, or the {@code web.xml} level. The default
066 * is 'false'.  When setting the URL value into a variable, escaping is not recommended.
067 *
068 * <p>Example usage:
069 * <pre class="code">&lt;spring:url value="/url/path/{variableName}"&gt;
070 *   &lt;spring:param name="variableName" value="more than JSTL c:url" /&gt;
071 * &lt;/spring:url&gt;</pre>
072 *
073 * <p>The above results in:
074 * {@code /currentApplicationContext/url/path/more%20than%20JSTL%20c%3Aurl}
075 *
076 * <table>
077 * <caption>Attribute Summary</caption>
078 * <thead>
079 * <tr>
080 * <th>Attribute</th>
081 * <th>Required?</th>
082 * <th>Runtime Expression?</th>
083 * <th>Description</th>
084 * </tr>
085 * </thead>
086 * <tbody>
087 * <tr>
088 * <td>value</td>
089 * <td>true</td>
090 * <td>true</td>
091 * <td>The URL to build. This value can include template {placeholders} that are
092 * replaced with the URL encoded value of the named parameter. Parameters
093 * must be defined using the param tag inside the body of this tag.</td>
094 * </tr>
095 * <tr>
096 * <td>context</td>
097 * <td>false</td>
098 * <td>true</td>
099 * <td>Specifies a remote application context path.
100 * The default is the current application context path.</td>
101 * </tr>
102 * <tr>
103 * <td>var</td>
104 * <td>false</td>
105 * <td>true</td>
106 * <td>The name of the variable to export the URL value to.
107 * If not specified the URL is written as output.</td>
108 * </tr>
109 * <tr>
110 * <td>scope</td>
111 * <td>false</td>
112 * <td>true</td>
113 * <td>The scope for the var. 'application', 'session', 'request' and 'page'
114 * scopes are supported. Defaults to page scope. This attribute has no
115 * effect unless the var attribute is also defined.</td>
116 * </tr>
117 * <tr>
118 * <td>htmlEscape</td>
119 * <td>false</td>
120 * <td>true</td>
121 * <td>Set HTML escaping for this tag, as a boolean value. Overrides the
122 * default HTML escaping setting for the current page.</td>
123 * </tr>
124 * <tr>
125 * <td>javaScriptEscape</td>
126 * <td>false</td>
127 * <td>true</td>
128 * <td>Set JavaScript escaping for this tag, as a boolean value.
129 * Default is false.</td>
130 * </tr>
131 * </tbody>
132 * </table>
133 *
134 * @author Scott Andrews
135 * @since 3.0
136 * @see ParamTag
137 */
138@SuppressWarnings("serial")
139public class UrlTag extends HtmlEscapingAwareTag implements ParamAware {
140
141        private static final String URL_TEMPLATE_DELIMITER_PREFIX = "{";
142
143        private static final String URL_TEMPLATE_DELIMITER_SUFFIX = "}";
144
145        private static final String URL_TYPE_ABSOLUTE = "://";
146
147
148        private List<Param> params = Collections.emptyList();
149
150        private Set<String> templateParams = Collections.emptySet();
151
152        @Nullable
153        private UrlType type;
154
155        @Nullable
156        private String value;
157
158        @Nullable
159        private String context;
160
161        @Nullable
162        private String var;
163
164        private int scope = PageContext.PAGE_SCOPE;
165
166        private boolean javaScriptEscape = false;
167
168
169        /**
170         * Set the value of the URL.
171         */
172        public void setValue(String value) {
173                if (value.contains(URL_TYPE_ABSOLUTE)) {
174                        this.type = UrlType.ABSOLUTE;
175                        this.value = value;
176                }
177                else if (value.startsWith("/")) {
178                        this.type = UrlType.CONTEXT_RELATIVE;
179                        this.value = value;
180                }
181                else {
182                        this.type = UrlType.RELATIVE;
183                        this.value = value;
184                }
185        }
186
187        /**
188         * Set the context path for the URL.
189         * Defaults to the current context.
190         */
191        public void setContext(String context) {
192                if (context.startsWith("/")) {
193                        this.context = context;
194                }
195                else {
196                        this.context = "/" + context;
197                }
198        }
199
200        /**
201         * Set the variable name to expose the URL under. Defaults to rendering the
202         * URL to the current JspWriter
203         */
204        public void setVar(String var) {
205                this.var = var;
206        }
207
208        /**
209         * Set the scope to export the URL variable to. This attribute has no
210         * meaning unless var is also defined.
211         */
212        public void setScope(String scope) {
213                this.scope = TagUtils.getScope(scope);
214        }
215
216        /**
217         * Set JavaScript escaping for this tag, as boolean value.
218         * Default is "false".
219         */
220        public void setJavaScriptEscape(boolean javaScriptEscape) throws JspException {
221                this.javaScriptEscape = javaScriptEscape;
222        }
223
224        @Override
225        public void addParam(Param param) {
226                this.params.add(param);
227        }
228
229
230        @Override
231        public int doStartTagInternal() throws JspException {
232                this.params = new LinkedList<>();
233                this.templateParams = new HashSet<>();
234                return EVAL_BODY_INCLUDE;
235        }
236
237        @Override
238        public int doEndTag() throws JspException {
239                String url = createUrl();
240
241                RequestDataValueProcessor processor = getRequestContext().getRequestDataValueProcessor();
242                ServletRequest request = this.pageContext.getRequest();
243                if ((processor != null) && (request instanceof HttpServletRequest)) {
244                        url = processor.processUrl((HttpServletRequest) request, url);
245                }
246
247                if (this.var == null) {
248                        // print the url to the writer
249                        try {
250                                this.pageContext.getOut().print(url);
251                        }
252                        catch (IOException ex) {
253                                throw new JspException(ex);
254                        }
255                }
256                else {
257                        // store the url as a variable
258                        this.pageContext.setAttribute(this.var, url, this.scope);
259                }
260                return EVAL_PAGE;
261        }
262
263
264        /**
265         * Build the URL for the tag from the tag attributes and parameters.
266         * @return the URL value as a String
267         */
268        String createUrl() throws JspException {
269                Assert.state(this.value != null, "No value set");
270                HttpServletRequest request = (HttpServletRequest) this.pageContext.getRequest();
271                HttpServletResponse response = (HttpServletResponse) this.pageContext.getResponse();
272
273                StringBuilder url = new StringBuilder();
274                if (this.type == UrlType.CONTEXT_RELATIVE) {
275                        // add application context to url
276                        if (this.context == null) {
277                                url.append(request.getContextPath());
278                        }
279                        else {
280                                if (this.context.endsWith("/")) {
281                                        url.append(this.context, 0, this.context.length() - 1);
282                                }
283                                else {
284                                        url.append(this.context);
285                                }
286                        }
287                }
288                if (this.type != UrlType.RELATIVE && this.type != UrlType.ABSOLUTE && !this.value.startsWith("/")) {
289                        url.append("/");
290                }
291                url.append(replaceUriTemplateParams(this.value, this.params, this.templateParams));
292                url.append(createQueryString(this.params, this.templateParams, (url.indexOf("?") == -1)));
293
294                String urlStr = url.toString();
295                if (this.type != UrlType.ABSOLUTE) {
296                        // Add the session identifier if needed
297                        // (Do not embed the session identifier in a remote link!)
298                        urlStr = response.encodeURL(urlStr);
299                }
300
301                // HTML and/or JavaScript escape, if demanded.
302                urlStr = htmlEscape(urlStr);
303                urlStr = (this.javaScriptEscape ? JavaScriptUtils.javaScriptEscape(urlStr) : urlStr);
304
305                return urlStr;
306        }
307
308        /**
309         * Build the query string from available parameters that have not already
310         * been applied as template params.
311         * <p>The names and values of parameters are URL encoded.
312         * @param params the parameters to build the query string from
313         * @param usedParams set of parameter names that have been applied as
314         * template params
315         * @param includeQueryStringDelimiter true if the query string should start
316         * with a '?' instead of '&'
317         * @return the query string
318         */
319        protected String createQueryString(List<Param> params, Set<String> usedParams, boolean includeQueryStringDelimiter)
320                        throws JspException {
321
322                String encoding = this.pageContext.getResponse().getCharacterEncoding();
323                StringBuilder qs = new StringBuilder();
324                for (Param param : params) {
325                        if (!usedParams.contains(param.getName()) && StringUtils.hasLength(param.getName())) {
326                                if (includeQueryStringDelimiter && qs.length() == 0) {
327                                        qs.append("?");
328                                }
329                                else {
330                                        qs.append("&");
331                                }
332                                try {
333                                        qs.append(UriUtils.encodeQueryParam(param.getName(), encoding));
334                                        if (param.getValue() != null) {
335                                                qs.append("=");
336                                                qs.append(UriUtils.encodeQueryParam(param.getValue(), encoding));
337                                        }
338                                }
339                                catch (UnsupportedCharsetException ex) {
340                                        throw new JspException(ex);
341                                }
342                        }
343                }
344                return qs.toString();
345        }
346
347        /**
348         * Replace template markers in the URL matching available parameters. The
349         * name of matched parameters are added to the used parameters set.
350         * <p>Parameter values are URL encoded.
351         * @param uri the URL with template parameters to replace
352         * @param params parameters used to replace template markers
353         * @param usedParams set of template parameter names that have been replaced
354         * @return the URL with template parameters replaced
355         */
356        protected String replaceUriTemplateParams(String uri, List<Param> params, Set<String> usedParams)
357                        throws JspException {
358
359                String encoding = this.pageContext.getResponse().getCharacterEncoding();
360                for (Param param : params) {
361                        String template = URL_TEMPLATE_DELIMITER_PREFIX + param.getName() + URL_TEMPLATE_DELIMITER_SUFFIX;
362                        if (uri.contains(template)) {
363                                usedParams.add(param.getName());
364                                String value = param.getValue();
365                                try {
366                                        uri = StringUtils.replace(uri, template,
367                                                        (value != null ? UriUtils.encodePath(value, encoding) : ""));
368                                }
369                                catch (UnsupportedCharsetException ex) {
370                                        throw new JspException(ex);
371                                }
372                        }
373                        else {
374                                template = URL_TEMPLATE_DELIMITER_PREFIX + '/' + param.getName() + URL_TEMPLATE_DELIMITER_SUFFIX;
375                                if (uri.contains(template)) {
376                                        usedParams.add(param.getName());
377                                        String value = param.getValue();
378                                        try {
379                                                uri = StringUtils.replace(uri, template,
380                                                                (value != null ? UriUtils.encodePathSegment(value, encoding) : ""));
381                                        }
382                                        catch (UnsupportedCharsetException ex) {
383                                                throw new JspException(ex);
384                                        }
385                                }
386                        }
387                }
388                return uri;
389        }
390
391
392        /**
393         * Internal enum that classifies URLs by type.
394         */
395        private enum UrlType {
396
397                CONTEXT_RELATIVE, RELATIVE, ABSOLUTE
398        }
399
400}