001/**
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.camel.util;
018
019import java.io.UnsupportedEncodingException;
020import java.net.URI;
021import java.net.URISyntaxException;
022import java.net.URLDecoder;
023import java.net.URLEncoder;
024import java.util.ArrayList;
025import java.util.Collection;
026import java.util.Collections;
027import java.util.Iterator;
028import java.util.LinkedHashMap;
029import java.util.List;
030import java.util.Map;
031import java.util.regex.Pattern;
032
033/**
034 * URI utilities.
035 *
036 * @version 
037 */
038public final class URISupport {
039
040    public static final String RAW_TOKEN_START = "RAW(";
041    public static final String RAW_TOKEN_END = ")";
042
043    // Match any key-value pair in the URI query string whose key contains
044    // "passphrase" or "password" or secret key (case-insensitive).
045    // First capture group is the key, second is the value.
046    private static final Pattern SECRETS = Pattern.compile("([?&][^=]*(?:passphrase|password|secretKey)[^=]*)=([^&]*)",
047            Pattern.CASE_INSENSITIVE);
048    
049    // Match the user password in the URI as second capture group
050    // (applies to URI with authority component and userinfo token in the form "user:password").
051    private static final Pattern USERINFO_PASSWORD = Pattern.compile("(.*://.*:)(.*)(@)");
052    
053    // Match the user password in the URI path as second capture group
054    // (applies to URI path with authority component and userinfo token in the form "user:password").
055    private static final Pattern PATH_USERINFO_PASSWORD = Pattern.compile("(.*:)(.*)(@)");
056    
057    private static final String CHARSET = "UTF-8";
058
059    private URISupport() {
060        // Helper class
061    }
062
063    /**
064     * Removes detected sensitive information (such as passwords) from the URI and returns the result.
065     *
066     * @param uri The uri to sanitize.
067     * @see #SECRETS for the matched pattern
068     *
069     * @return Returns null if the uri is null, otherwise the URI with the passphrase, password or secretKey sanitized.
070     */
071    public static String sanitizeUri(String uri) {
072        // use xxxxx as replacement as that works well with JMX also
073        String sanitized = uri;
074        if (uri != null) {
075            sanitized = SECRETS.matcher(sanitized).replaceAll("$1=xxxxxx");
076            sanitized = USERINFO_PASSWORD.matcher(sanitized).replaceFirst("$1xxxxxx$3");
077        }
078        return sanitized;
079    }
080    
081    /**
082     * Removes detected sensitive information (such as passwords) from the
083     * <em>path part</em> of an URI (that is, the part without the query
084     * parameters or component prefix) and returns the result.
085     * 
086     * @param path the URI path to sanitize
087     * @return null if the path is null, otherwise the sanitized path
088     */
089    public static String sanitizePath(String path) {
090        String sanitized = path;
091        if (path != null) {
092            sanitized = PATH_USERINFO_PASSWORD.matcher(sanitized).replaceFirst("$1xxxxxx$3");
093        }
094        return sanitized;
095    }
096
097    /**
098     * Parses the query part of the uri (eg the parameters).
099     * <p/>
100     * The URI parameters will by default be URI encoded. However you can define a parameter
101     * values with the syntax: <tt>key=RAW(value)</tt> which tells Camel to not encode the value,
102     * and use the value as is (eg key=value) and the value has <b>not</b> been encoded.
103     *
104     * @param uri the uri
105     * @return the parameters, or an empty map if no parameters (eg never null)
106     * @throws URISyntaxException is thrown if uri has invalid syntax.
107     * @see #RAW_TOKEN_START
108     * @see #RAW_TOKEN_END
109     */
110    public static Map<String, Object> parseQuery(String uri) throws URISyntaxException {
111        return parseQuery(uri, false);
112    }
113
114    /**
115     * Parses the query part of the uri (eg the parameters).
116     * <p/>
117     * The URI parameters will by default be URI encoded. However you can define a parameter
118     * values with the syntax: <tt>key=RAW(value)</tt> which tells Camel to not encode the value,
119     * and use the value as is (eg key=value) and the value has <b>not</b> been encoded.
120     *
121     * @param uri the uri
122     * @param useRaw whether to force using raw values
123     * @return the parameters, or an empty map if no parameters (eg never null)
124     * @throws URISyntaxException is thrown if uri has invalid syntax.
125     * @see #RAW_TOKEN_START
126     * @see #RAW_TOKEN_END
127     */
128    public static Map<String, Object> parseQuery(String uri, boolean useRaw) throws URISyntaxException {
129        // must check for trailing & as the uri.split("&") will ignore those
130        if (uri != null && uri.endsWith("&")) {
131            throw new URISyntaxException(uri, "Invalid uri syntax: Trailing & marker found. "
132                    + "Check the uri and remove the trailing & marker.");
133        }
134
135        if (ObjectHelper.isEmpty(uri)) {
136            // return an empty map
137            return new LinkedHashMap<String, Object>(0);
138        }
139
140        // need to parse the uri query parameters manually as we cannot rely on splitting by &,
141        // as & can be used in a parameter value as well.
142
143        try {
144            // use a linked map so the parameters is in the same order
145            Map<String, Object> rc = new LinkedHashMap<String, Object>();
146
147            boolean isKey = true;
148            boolean isValue = false;
149            boolean isRaw = false;
150            StringBuilder key = new StringBuilder();
151            StringBuilder value = new StringBuilder();
152
153            // parse the uri parameters char by char
154            for (int i = 0; i < uri.length(); i++) {
155                // current char
156                char ch = uri.charAt(i);
157                // look ahead of the next char
158                char next;
159                if (i <= uri.length() - 2) {
160                    next = uri.charAt(i + 1);
161                } else {
162                    next = '\u0000';
163                }
164
165                // are we a raw value
166                isRaw = value.toString().startsWith(RAW_TOKEN_START);
167
168                // if we are in raw mode, then we keep adding until we hit the end marker
169                if (isRaw) {
170                    if (isKey) {
171                        key.append(ch);
172                    } else if (isValue) {
173                        value.append(ch);
174                    }
175
176                    // we only end the raw marker if its )& or at the end of the value
177
178                    boolean end = ch == RAW_TOKEN_END.charAt(0) && (next == '&' || next == '\u0000');
179                    if (end) {
180                        // raw value end, so add that as a parameter, and reset flags
181                        addParameter(key.toString(), value.toString(), rc, useRaw || isRaw);
182                        key.setLength(0);
183                        value.setLength(0);
184                        isKey = true;
185                        isValue = false;
186                        isRaw = false;
187                        // skip to next as we are in raw mode and have already added the value
188                        i++;
189                    }
190                    continue;
191                }
192
193                // if its a key and there is a = sign then the key ends and we are in value mode
194                if (isKey && ch == '=') {
195                    isKey = false;
196                    isValue = true;
197                    isRaw = false;
198                    continue;
199                }
200
201                // the & denote parameter is ended
202                if (ch == '&') {
203                    // parameter is ended, as we hit & separator
204                    addParameter(key.toString(), value.toString(), rc, useRaw || isRaw);
205                    key.setLength(0);
206                    value.setLength(0);
207                    isKey = true;
208                    isValue = false;
209                    isRaw = false;
210                    continue;
211                }
212
213                // regular char so add it to the key or value
214                if (isKey) {
215                    key.append(ch);
216                } else if (isValue) {
217                    value.append(ch);
218                }
219            }
220
221            // any left over parameters, then add that
222            if (key.length() > 0) {
223                addParameter(key.toString(), value.toString(), rc, useRaw || isRaw);
224            }
225
226            return rc;
227
228        } catch (UnsupportedEncodingException e) {
229            URISyntaxException se = new URISyntaxException(e.toString(), "Invalid encoding");
230            se.initCause(e);
231            throw se;
232        }
233    }
234
235    private static void addParameter(String name, String value, Map<String, Object> map, boolean isRaw) throws UnsupportedEncodingException {
236        name = URLDecoder.decode(name, CHARSET);
237        if (!isRaw) {
238            // need to replace % with %25
239            value = URLDecoder.decode(value.replaceAll("%", "%25"), CHARSET);
240        }
241
242        // does the key already exist?
243        if (map.containsKey(name)) {
244            // yes it does, so make sure we can support multiple values, but using a list
245            // to hold the multiple values
246            Object existing = map.get(name);
247            List<String> list;
248            if (existing instanceof List) {
249                list = CastUtils.cast((List<?>) existing);
250            } else {
251                // create a new list to hold the multiple values
252                list = new ArrayList<String>();
253                String s = existing != null ? existing.toString() : null;
254                if (s != null) {
255                    list.add(s);
256                }
257            }
258            list.add(value);
259            map.put(name, list);
260        } else {
261            map.put(name, value);
262        }
263    }
264
265    /**
266     * Parses the query parameters of the uri (eg the query part).
267     *
268     * @param uri the uri
269     * @return the parameters, or an empty map if no parameters (eg never null)
270     * @throws URISyntaxException is thrown if uri has invalid syntax.
271     */
272    public static Map<String, Object> parseParameters(URI uri) throws URISyntaxException {
273        String query = uri.getQuery();
274        if (query == null) {
275            String schemeSpecificPart = uri.getSchemeSpecificPart();
276            int idx = schemeSpecificPart.indexOf('?');
277            if (idx < 0) {
278                // return an empty map
279                return new LinkedHashMap<String, Object>(0);
280            } else {
281                query = schemeSpecificPart.substring(idx + 1);
282            }
283        } else {
284            query = stripPrefix(query, "?");
285        }
286        return parseQuery(query);
287    }
288
289    /**
290     * Traverses the given parameters, and resolve any parameter values which uses the RAW token
291     * syntax: <tt>key=RAW(value)</tt>. This method will then remove the RAW tokens, and replace
292     * the content of the value, with just the value.
293     *
294     * @param parameters the uri parameters
295     * @see #parseQuery(String)
296     * @see #RAW_TOKEN_START
297     * @see #RAW_TOKEN_END
298     */
299    @SuppressWarnings("unchecked")
300    public static void resolveRawParameterValues(Map<String, Object> parameters) {
301        for (Map.Entry<String, Object> entry : parameters.entrySet()) {
302            if (entry.getValue() != null) {
303                // if the value is a list then we need to iterate
304                Object value = entry.getValue();
305                if (value instanceof List) {
306                    List list = (List) value;
307                    for (int i = 0; i < list.size(); i++) {
308                        Object obj = list.get(i);
309                        if (obj != null) {
310                            String str = obj.toString();
311                            if (str.startsWith(RAW_TOKEN_START) && str.endsWith(RAW_TOKEN_END)) {
312                                str = str.substring(4, str.length() - 1);
313                                // update the string in the list
314                                list.set(i, str);
315                            }
316                        }
317                    }
318                } else {
319                    String str = entry.getValue().toString();
320                    if (str.startsWith(RAW_TOKEN_START) && str.endsWith(RAW_TOKEN_END)) {
321                        str = str.substring(4, str.length() - 1);
322                        entry.setValue(str);
323                    }
324                }
325            }
326        }
327    }
328
329    /**
330     * Creates a URI with the given query
331     *
332     * @param uri the uri
333     * @param query the query to append to the uri
334     * @return uri with the query appended
335     * @throws URISyntaxException is thrown if uri has invalid syntax.
336     */
337    public static URI createURIWithQuery(URI uri, String query) throws URISyntaxException {
338        ObjectHelper.notNull(uri, "uri");
339
340        // assemble string as new uri and replace parameters with the query instead
341        String s = uri.toString();
342        String before = ObjectHelper.before(s, "?");
343        if (before == null) {
344            before = ObjectHelper.before(s, "#");
345        }
346        if (before != null) {
347            s = before;
348        }
349        if (query != null) {
350            s = s + "?" + query;
351        }
352        if ((!s.contains("#")) && (uri.getFragment() != null)) {
353            s = s + "#" + uri.getFragment();
354        }
355
356        return new URI(s);
357    }
358
359    /**
360     * Strips the prefix from the value.
361     * <p/>
362     * Returns the value as-is if not starting with the prefix.
363     *
364     * @param value  the value
365     * @param prefix the prefix to remove from value
366     * @return the value without the prefix
367     */
368    public static String stripPrefix(String value, String prefix) {
369        if (value != null && value.startsWith(prefix)) {
370            return value.substring(prefix.length());
371        }
372        return value;
373    }
374
375    /**
376     * Assembles a query from the given map.
377     *
378     * @param options  the map with the options (eg key/value pairs)
379     * @return a query string with <tt>key1=value&key2=value2&...</tt>, or an empty string if there is no options.
380     * @throws URISyntaxException is thrown if uri has invalid syntax.
381     */
382    @SuppressWarnings("unchecked")
383    public static String createQueryString(Map<String, Object> options) throws URISyntaxException {
384        try {
385            if (options.size() > 0) {
386                StringBuilder rc = new StringBuilder();
387                boolean first = true;
388                for (Object o : options.keySet()) {
389                    if (first) {
390                        first = false;
391                    } else {
392                        rc.append("&");
393                    }
394
395                    String key = (String) o;
396                    Object value = options.get(key);
397
398                    // the value may be a list since the same key has multiple values
399                    if (value instanceof List) {
400                        List<String> list = (List<String>) value;
401                        for (Iterator<String> it = list.iterator(); it.hasNext();) {
402                            String s = it.next();
403                            appendQueryStringParameter(key, s, rc);
404                            // append & separator if there is more in the list to append
405                            if (it.hasNext()) {
406                                rc.append("&");
407                            }
408                        }
409                    } else {
410                        // use the value as a String
411                        String s = value != null ? value.toString() : null;
412                        appendQueryStringParameter(key, s, rc);
413                    }
414                }
415                return rc.toString();
416            } else {
417                return "";
418            }
419        } catch (UnsupportedEncodingException e) {
420            URISyntaxException se = new URISyntaxException(e.toString(), "Invalid encoding");
421            se.initCause(e);
422            throw se;
423        }
424    }
425
426    private static void appendQueryStringParameter(String key, String value, StringBuilder rc) throws UnsupportedEncodingException {
427        rc.append(URLEncoder.encode(key, CHARSET));
428        // only append if value is not null
429        if (value != null) {
430            rc.append("=");
431            if (value.startsWith(RAW_TOKEN_START) && value.endsWith(RAW_TOKEN_END)) {
432                // do not encode RAW parameters unless it has %
433                // need to replace % with %25 to avoid losing "%" when decoding
434                String s = StringHelper.replaceAll(value, "%", "%25");
435                rc.append(s);
436            } else {
437                rc.append(URLEncoder.encode(value, CHARSET));
438            }
439        }
440    }
441
442    /**
443     * Creates a URI from the original URI and the remaining parameters
444     * <p/>
445     * Used by various Camel components
446     */
447    public static URI createRemainingURI(URI originalURI, Map<String, Object> params) throws URISyntaxException {
448        String s = createQueryString(params);
449        if (s.length() == 0) {
450            s = null;
451        }
452        return createURIWithQuery(originalURI, s);
453    }
454
455    /**
456     * Normalizes the uri by reordering the parameters so they are sorted and thus
457     * we can use the uris for endpoint matching.
458     * <p/>
459     * The URI parameters will by default be URI encoded. However you can define a parameter
460     * values with the syntax: <tt>key=RAW(value)</tt> which tells Camel to not encode the value,
461     * and use the value as is (eg key=value) and the value has <b>not</b> been encoded.
462     *
463     * @param uri the uri
464     * @return the normalized uri
465     * @throws URISyntaxException in thrown if the uri syntax is invalid
466     * @throws UnsupportedEncodingException is thrown if encoding error
467     * @see #RAW_TOKEN_START
468     * @see #RAW_TOKEN_END
469     */
470    public static String normalizeUri(String uri) throws URISyntaxException, UnsupportedEncodingException {
471
472        URI u = new URI(UnsafeUriCharactersEncoder.encode(uri, true));
473        String path = u.getSchemeSpecificPart();
474        String scheme = u.getScheme();
475
476        // not possible to normalize
477        if (scheme == null || path == null) {
478            return uri;
479        }
480
481        // lets trim off any query arguments
482        if (path.startsWith("//")) {
483            path = path.substring(2);
484        }
485        int idx = path.indexOf('?');
486        // when the path has ?
487        if (idx != -1) {
488            path = path.substring(0, idx);
489        }
490
491        if (u.getScheme().startsWith("http")) {
492            path = UnsafeUriCharactersEncoder.encodeHttpURI(path);
493        } else {
494            path = UnsafeUriCharactersEncoder.encode(path);
495        }
496
497        // okay if we have user info in the path and they use @ in username or password,
498        // then we need to encode them (but leave the last @ sign before the hostname)
499        // this is needed as Camel end users may not encode their user info properly, but expect
500        // this to work out of the box with Camel, and hence we need to fix it for them
501        String userInfoPath = path;
502        if (userInfoPath.contains("/")) {
503            userInfoPath = userInfoPath.substring(0, userInfoPath.indexOf("/"));
504        }
505        if (StringHelper.countChar(userInfoPath, '@') > 1) {
506            int max = userInfoPath.lastIndexOf('@');
507            String before = userInfoPath.substring(0, max);
508            // after must be from original path
509            String after = path.substring(max);
510
511            // replace the @ with %40
512            before = StringHelper.replaceAll(before, "@", "%40");
513            path = before + after;
514        }
515
516        // in case there are parameters we should reorder them
517        Map<String, Object> parameters = URISupport.parseParameters(u);
518        if (parameters.isEmpty()) {
519            // no parameters then just return
520            return buildUri(scheme, path, null);
521        } else {
522            // reorder parameters a..z
523            List<String> keys = new ArrayList<String>(parameters.keySet());
524            Collections.sort(keys);
525
526            Map<String, Object> sorted = new LinkedHashMap<String, Object>(parameters.size());
527            for (String key : keys) {
528                sorted.put(key, parameters.get(key));
529            }
530
531            // build uri object with sorted parameters
532            String query = URISupport.createQueryString(sorted);
533            return buildUri(scheme, path, query);
534        }
535    }
536
537    private static String buildUri(String scheme, String path, String query) {
538        // must include :// to do a correct URI all components can work with
539        return scheme + "://" + path + (query != null ? "?" + query : "");
540    }
541}