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.Collections;
026import java.util.Iterator;
027import java.util.LinkedHashMap;
028import java.util.List;
029import java.util.Map;
030import java.util.regex.Pattern;
031
032/**
033 * URI utilities.
034 *
035 * @version 
036 */
037public 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 and #USERINFO_PASSWORD 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     * Extracts the scheme specific path from the URI that is used as the remainder option when creating endpoints.
098     *
099     * @param u      the URI
100     * @param useRaw whether to force using raw values
101     * @return the remainder path
102     */
103    public static String extractRemainderPath(URI u, boolean useRaw) {
104        String path = useRaw ? u.getRawSchemeSpecificPart() : u.getSchemeSpecificPart();
105
106        // lets trim off any query arguments
107        if (path.startsWith("//")) {
108            path = path.substring(2);
109        }
110        int idx = path.indexOf('?');
111        if (idx > -1) {
112            path = path.substring(0, idx);
113        }
114
115        return path;
116    }
117
118    /**
119     * Parses the query part of the uri (eg the parameters).
120     * <p/>
121     * The URI parameters will by default be URI encoded. However you can define a parameter
122     * values with the syntax: <tt>key=RAW(value)</tt> which tells Camel to not encode the value,
123     * and use the value as is (eg key=value) and the value has <b>not</b> been encoded.
124     *
125     * @param uri the uri
126     * @return the parameters, or an empty map if no parameters (eg never null)
127     * @throws URISyntaxException is thrown if uri has invalid syntax.
128     * @see #RAW_TOKEN_START
129     * @see #RAW_TOKEN_END
130     */
131    public static Map<String, Object> parseQuery(String uri) throws URISyntaxException {
132        return parseQuery(uri, false);
133    }
134
135    /**
136     * Parses the query part of the uri (eg the parameters).
137     * <p/>
138     * The URI parameters will by default be URI encoded. However you can define a parameter
139     * values with the syntax: <tt>key=RAW(value)</tt> which tells Camel to not encode the value,
140     * and use the value as is (eg key=value) and the value has <b>not</b> been encoded.
141     *
142     * @param uri the uri
143     * @param useRaw whether to force using raw values
144     * @return the parameters, or an empty map if no parameters (eg never null)
145     * @throws URISyntaxException is thrown if uri has invalid syntax.
146     * @see #RAW_TOKEN_START
147     * @see #RAW_TOKEN_END
148     */
149    public static Map<String, Object> parseQuery(String uri, boolean useRaw) throws URISyntaxException {
150        return parseQuery(uri, useRaw, false);
151    }
152
153    /**
154     * Parses the query part of the uri (eg the parameters).
155     * <p/>
156     * The URI parameters will by default be URI encoded. However you can define a parameter
157     * values with the syntax: <tt>key=RAW(value)</tt> which tells Camel to not encode the value,
158     * and use the value as is (eg key=value) and the value has <b>not</b> been encoded.
159     *
160     * @param uri the uri
161     * @param useRaw whether to force using raw values
162     * @param lenient whether to parse lenient and ignore trailing & markers which has no key or value which can happen when using HTTP components
163     * @return the parameters, or an empty map if no parameters (eg never null)
164     * @throws URISyntaxException is thrown if uri has invalid syntax.
165     * @see #RAW_TOKEN_START
166     * @see #RAW_TOKEN_END
167     */
168    public static Map<String, Object> parseQuery(String uri, boolean useRaw, boolean lenient) throws URISyntaxException {
169        // must check for trailing & as the uri.split("&") will ignore those
170        if (!lenient) {
171            if (uri != null && uri.endsWith("&")) {
172                throw new URISyntaxException(uri, "Invalid uri syntax: Trailing & marker found. "
173                        + "Check the uri and remove the trailing & marker.");
174            }
175        }
176
177        if (uri == null || ObjectHelper.isEmpty(uri)) {
178            // return an empty map
179            return new LinkedHashMap<String, Object>(0);
180        }
181
182        // need to parse the uri query parameters manually as we cannot rely on splitting by &,
183        // as & can be used in a parameter value as well.
184
185        try {
186            // use a linked map so the parameters is in the same order
187            Map<String, Object> rc = new LinkedHashMap<String, Object>();
188
189            boolean isKey = true;
190            boolean isValue = false;
191            boolean isRaw = false;
192            StringBuilder key = new StringBuilder();
193            StringBuilder value = new StringBuilder();
194
195            // parse the uri parameters char by char
196            for (int i = 0; i < uri.length(); i++) {
197                // current char
198                char ch = uri.charAt(i);
199                // look ahead of the next char
200                char next;
201                if (i <= uri.length() - 2) {
202                    next = uri.charAt(i + 1);
203                } else {
204                    next = '\u0000';
205                }
206
207                // are we a raw value
208                isRaw = value.toString().startsWith(RAW_TOKEN_START);
209
210                // if we are in raw mode, then we keep adding until we hit the end marker
211                if (isRaw) {
212                    if (isKey) {
213                        key.append(ch);
214                    } else if (isValue) {
215                        value.append(ch);
216                    }
217
218                    // we only end the raw marker if its )& or at the end of the value
219
220                    boolean end = ch == RAW_TOKEN_END.charAt(0) && (next == '&' || next == '\u0000');
221                    if (end) {
222                        // raw value end, so add that as a parameter, and reset flags
223                        addParameter(key.toString(), value.toString(), rc, useRaw || isRaw);
224                        key.setLength(0);
225                        value.setLength(0);
226                        isKey = true;
227                        isValue = false;
228                        isRaw = false;
229                        // skip to next as we are in raw mode and have already added the value
230                        i++;
231                    }
232                    continue;
233                }
234
235                // if its a key and there is a = sign then the key ends and we are in value mode
236                if (isKey && ch == '=') {
237                    isKey = false;
238                    isValue = true;
239                    isRaw = false;
240                    continue;
241                }
242
243                // the & denote parameter is ended
244                if (ch == '&') {
245                    // parameter is ended, as we hit & separator
246                    addParameter(key.toString(), value.toString(), rc, useRaw || isRaw);
247                    key.setLength(0);
248                    value.setLength(0);
249                    isKey = true;
250                    isValue = false;
251                    isRaw = false;
252                    continue;
253                }
254
255                // regular char so add it to the key or value
256                if (isKey) {
257                    key.append(ch);
258                } else if (isValue) {
259                    value.append(ch);
260                }
261            }
262
263            // any left over parameters, then add that
264            if (key.length() > 0) {
265                addParameter(key.toString(), value.toString(), rc, useRaw || isRaw);
266            }
267
268            return rc;
269
270        } catch (UnsupportedEncodingException e) {
271            URISyntaxException se = new URISyntaxException(e.toString(), "Invalid encoding");
272            se.initCause(e);
273            throw se;
274        }
275    }
276
277    private static void addParameter(String name, String value, Map<String, Object> map, boolean isRaw) throws UnsupportedEncodingException {
278        name = URLDecoder.decode(name, CHARSET);
279        if (!isRaw) {
280            // need to replace % with %25
281            String s = StringHelper.replaceAll(value, "%", "%25");
282            value = URLDecoder.decode(s, CHARSET);
283        }
284
285        // does the key already exist?
286        if (map.containsKey(name)) {
287            // yes it does, so make sure we can support multiple values, but using a list
288            // to hold the multiple values
289            Object existing = map.get(name);
290            List<String> list;
291            if (existing instanceof List) {
292                list = CastUtils.cast((List<?>) existing);
293            } else {
294                // create a new list to hold the multiple values
295                list = new ArrayList<String>();
296                String s = existing != null ? existing.toString() : null;
297                if (s != null) {
298                    list.add(s);
299                }
300            }
301            list.add(value);
302            map.put(name, list);
303        } else {
304            map.put(name, value);
305        }
306    }
307
308    /**
309     * Parses the query parameters of the uri (eg the query part).
310     *
311     * @param uri the uri
312     * @return the parameters, or an empty map if no parameters (eg never null)
313     * @throws URISyntaxException is thrown if uri has invalid syntax.
314     */
315    public static Map<String, Object> parseParameters(URI uri) throws URISyntaxException {
316        String query = uri.getQuery();
317        if (query == null) {
318            String schemeSpecificPart = uri.getSchemeSpecificPart();
319            int idx = schemeSpecificPart.indexOf('?');
320            if (idx < 0) {
321                // return an empty map
322                return new LinkedHashMap<String, Object>(0);
323            } else {
324                query = schemeSpecificPart.substring(idx + 1);
325            }
326        } else {
327            query = stripPrefix(query, "?");
328        }
329        return parseQuery(query);
330    }
331
332    /**
333     * Traverses the given parameters, and resolve any parameter values which uses the RAW token
334     * syntax: <tt>key=RAW(value)</tt>. This method will then remove the RAW tokens, and replace
335     * the content of the value, with just the value.
336     *
337     * @param parameters the uri parameters
338     * @see #parseQuery(String)
339     * @see #RAW_TOKEN_START
340     * @see #RAW_TOKEN_END
341     */
342    @SuppressWarnings("unchecked")
343    public static void resolveRawParameterValues(Map<String, Object> parameters) {
344        for (Map.Entry<String, Object> entry : parameters.entrySet()) {
345            if (entry.getValue() != null) {
346                // if the value is a list then we need to iterate
347                Object value = entry.getValue();
348                if (value instanceof List) {
349                    List list = (List) value;
350                    for (int i = 0; i < list.size(); i++) {
351                        Object obj = list.get(i);
352                        if (obj != null) {
353                            String str = obj.toString();
354                            if (str.startsWith(RAW_TOKEN_START) && str.endsWith(RAW_TOKEN_END)) {
355                                str = str.substring(4, str.length() - 1);
356                                // update the string in the list
357                                list.set(i, str);
358                            }
359                        }
360                    }
361                } else {
362                    String str = entry.getValue().toString();
363                    if (str.startsWith(RAW_TOKEN_START) && str.endsWith(RAW_TOKEN_END)) {
364                        str = str.substring(4, str.length() - 1);
365                        entry.setValue(str);
366                    }
367                }
368            }
369        }
370    }
371
372    /**
373     * Creates a URI with the given query
374     *
375     * @param uri the uri
376     * @param query the query to append to the uri
377     * @return uri with the query appended
378     * @throws URISyntaxException is thrown if uri has invalid syntax.
379     */
380    public static URI createURIWithQuery(URI uri, String query) throws URISyntaxException {
381        ObjectHelper.notNull(uri, "uri");
382
383        // assemble string as new uri and replace parameters with the query instead
384        String s = uri.toString();
385        String before = ObjectHelper.before(s, "?");
386        if (before == null) {
387            before = ObjectHelper.before(s, "#");
388        }
389        if (before != null) {
390            s = before;
391        }
392        if (query != null) {
393            s = s + "?" + query;
394        }
395        if ((!s.contains("#")) && (uri.getFragment() != null)) {
396            s = s + "#" + uri.getFragment();
397        }
398
399        return new URI(s);
400    }
401
402    /**
403     * Strips the prefix from the value.
404     * <p/>
405     * Returns the value as-is if not starting with the prefix.
406     *
407     * @param value  the value
408     * @param prefix the prefix to remove from value
409     * @return the value without the prefix
410     */
411    public static String stripPrefix(String value, String prefix) {
412        if (value != null && value.startsWith(prefix)) {
413            return value.substring(prefix.length());
414        }
415        return value;
416    }
417
418    /**
419     * Assembles a query from the given map.
420     *
421     * @param options  the map with the options (eg key/value pairs)
422     * @return a query string with <tt>key1=value&key2=value2&...</tt>, or an empty string if there is no options.
423     * @throws URISyntaxException is thrown if uri has invalid syntax.
424     */
425    @SuppressWarnings("unchecked")
426    public static String createQueryString(Map<String, Object> options) throws URISyntaxException {
427        try {
428            if (options.size() > 0) {
429                StringBuilder rc = new StringBuilder();
430                boolean first = true;
431                for (Object o : options.keySet()) {
432                    if (first) {
433                        first = false;
434                    } else {
435                        rc.append("&");
436                    }
437
438                    String key = (String) o;
439                    Object value = options.get(key);
440
441                    // the value may be a list since the same key has multiple values
442                    if (value instanceof List) {
443                        List<String> list = (List<String>) value;
444                        for (Iterator<String> it = list.iterator(); it.hasNext();) {
445                            String s = it.next();
446                            appendQueryStringParameter(key, s, rc);
447                            // append & separator if there is more in the list to append
448                            if (it.hasNext()) {
449                                rc.append("&");
450                            }
451                        }
452                    } else {
453                        // use the value as a String
454                        String s = value != null ? value.toString() : null;
455                        appendQueryStringParameter(key, s, rc);
456                    }
457                }
458                return rc.toString();
459            } else {
460                return "";
461            }
462        } catch (UnsupportedEncodingException e) {
463            URISyntaxException se = new URISyntaxException(e.toString(), "Invalid encoding");
464            se.initCause(e);
465            throw se;
466        }
467    }
468
469    private static void appendQueryStringParameter(String key, String value, StringBuilder rc) throws UnsupportedEncodingException {
470        rc.append(URLEncoder.encode(key, CHARSET));
471        // only append if value is not null
472        if (value != null) {
473            rc.append("=");
474            if (value.startsWith(RAW_TOKEN_START) && value.endsWith(RAW_TOKEN_END)) {
475                // do not encode RAW parameters unless it has %
476                // need to replace % with %25 to avoid losing "%" when decoding
477                String s = StringHelper.replaceAll(value, "%", "%25");
478                rc.append(s);
479            } else {
480                rc.append(URLEncoder.encode(value, CHARSET));
481            }
482        }
483    }
484
485    /**
486     * Creates a URI from the original URI and the remaining parameters
487     * <p/>
488     * Used by various Camel components
489     */
490    public static URI createRemainingURI(URI originalURI, Map<String, Object> params) throws URISyntaxException {
491        String s = createQueryString(params);
492        if (s.length() == 0) {
493            s = null;
494        }
495        return createURIWithQuery(originalURI, s);
496    }
497
498    /**
499     * Appends the given parameters to the given URI.
500     * <p/>
501     * It keeps the original parameters and if a new parameter is already defined in
502     * {@code originalURI}, it will be replaced by its value in {@code newParameters}.
503     *
504     * @param originalURI   the original URI
505     * @param newParameters the parameters to add
506     * @return the URI with all the parameters
507     * @throws URISyntaxException           is thrown if the uri syntax is invalid
508     * @throws UnsupportedEncodingException is thrown if encoding error
509     */
510    public static String appendParametersToURI(String originalURI, Map<String, Object> newParameters) throws URISyntaxException, UnsupportedEncodingException {
511        URI uri = new URI(normalizeUri(originalURI));
512        Map<String, Object> parameters = parseParameters(uri);
513        parameters.putAll(newParameters);
514        return createRemainingURI(uri, parameters).toString();
515    }
516
517    /**
518     * Normalizes the uri by reordering the parameters so they are sorted and thus
519     * we can use the uris for endpoint matching.
520     * <p/>
521     * The URI parameters will by default be URI encoded. However you can define a parameter
522     * values with the syntax: <tt>key=RAW(value)</tt> which tells Camel to not encode the value,
523     * and use the value as is (eg key=value) and the value has <b>not</b> been encoded.
524     *
525     * @param uri the uri
526     * @return the normalized uri
527     * @throws URISyntaxException in thrown if the uri syntax is invalid
528     * @throws UnsupportedEncodingException is thrown if encoding error
529     * @see #RAW_TOKEN_START
530     * @see #RAW_TOKEN_END
531     */
532    public static String normalizeUri(String uri) throws URISyntaxException, UnsupportedEncodingException {
533
534        URI u = new URI(UnsafeUriCharactersEncoder.encode(uri, true));
535        String path = u.getSchemeSpecificPart();
536        String scheme = u.getScheme();
537
538        // not possible to normalize
539        if (scheme == null || path == null) {
540            return uri;
541        }
542
543        // lets trim off any query arguments
544        if (path.startsWith("//")) {
545            path = path.substring(2);
546        }
547        int idx = path.indexOf('?');
548        // when the path has ?
549        if (idx != -1) {
550            path = path.substring(0, idx);
551        }
552
553        if (u.getScheme().startsWith("http")) {
554            path = UnsafeUriCharactersEncoder.encodeHttpURI(path);
555        } else {
556            path = UnsafeUriCharactersEncoder.encode(path);
557        }
558
559        // okay if we have user info in the path and they use @ in username or password,
560        // then we need to encode them (but leave the last @ sign before the hostname)
561        // this is needed as Camel end users may not encode their user info properly, but expect
562        // this to work out of the box with Camel, and hence we need to fix it for them
563        String userInfoPath = path;
564        if (userInfoPath.contains("/")) {
565            userInfoPath = userInfoPath.substring(0, userInfoPath.indexOf("/"));
566        }
567        if (StringHelper.countChar(userInfoPath, '@') > 1) {
568            int max = userInfoPath.lastIndexOf('@');
569            String before = userInfoPath.substring(0, max);
570            // after must be from original path
571            String after = path.substring(max);
572
573            // replace the @ with %40
574            before = StringHelper.replaceAll(before, "@", "%40");
575            path = before + after;
576        }
577
578        // in case there are parameters we should reorder them
579        Map<String, Object> parameters = URISupport.parseParameters(u);
580        if (parameters.isEmpty()) {
581            // no parameters then just return
582            return buildUri(scheme, path, null);
583        } else {
584            // reorder parameters a..z
585            List<String> keys = new ArrayList<String>(parameters.keySet());
586            keys.sort(null);
587
588            Map<String, Object> sorted = new LinkedHashMap<String, Object>(parameters.size());
589            for (String key : keys) {
590                sorted.put(key, parameters.get(key));
591            }
592
593            // build uri object with sorted parameters
594            String query = URISupport.createQueryString(sorted);
595            return buildUri(scheme, path, query);
596        }
597    }
598
599    private static String buildUri(String scheme, String path, String query) {
600        // must include :// to do a correct URI all components can work with
601        return scheme + "://" + path + (query != null ? "?" + query : "");
602    }
603
604    public static Map<String, Object> extractProperties(Map<String, Object> properties, String optionPrefix) {
605        Map<String, Object> rc = new LinkedHashMap<String, Object>(properties.size());
606
607        for (Iterator<Map.Entry<String, Object>> it = properties.entrySet().iterator(); it.hasNext();) {
608            Map.Entry<String, Object> entry = it.next();
609            String name = entry.getKey();
610            if (name.startsWith(optionPrefix)) {
611                Object value = properties.get(name);
612                name = name.substring(optionPrefix.length());
613                rc.put(name, value);
614                it.remove();
615            }
616        }
617
618        return rc;
619    }
620
621}