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