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