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