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