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)[^=]*)=(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                    final int index = i;
285                    URIScanner.resolveRaw(str, (s, raw) -> {
286                        // update the string in the list
287                        list.set(index, raw);
288                    });
289                }
290            } else {
291                String str = entry.getValue().toString();
292                URIScanner.resolveRaw(str, (s, raw) -> entry.setValue(raw));
293            }
294        }
295    }
296
297    /**
298     * Creates a URI with the given query
299     *
300     * @param uri the uri
301     * @param query the query to append to the uri
302     * @return uri with the query appended
303     * @throws URISyntaxException is thrown if uri has invalid syntax.
304     */
305    public static URI createURIWithQuery(URI uri, String query) throws URISyntaxException {
306        ObjectHelper.notNull(uri, "uri");
307
308        // assemble string as new uri and replace parameters with the query
309        // instead
310        String s = uri.toString();
311        String before = StringHelper.before(s, "?");
312        if (before == null) {
313            before = StringHelper.before(s, "#");
314        }
315        if (before != null) {
316            s = before;
317        }
318        if (query != null) {
319            s = s + "?" + query;
320        }
321        if ((!s.contains("#")) && (uri.getFragment() != null)) {
322            s = s + "#" + uri.getFragment();
323        }
324
325        return new URI(s);
326    }
327
328    /**
329     * Strips the prefix from the value.
330     * <p/>
331     * Returns the value as-is if not starting with the prefix.
332     *
333     * @param value the value
334     * @param prefix the prefix to remove from value
335     * @return the value without the prefix
336     */
337    public static String stripPrefix(String value, String prefix) {
338        if (value == null || prefix == null) {
339            return value;
340        }
341
342        if (value.startsWith(prefix)) {
343            return value.substring(prefix.length());
344        }
345
346        return value;
347    }
348
349    /**
350     * Strips the suffix from the value.
351     * <p/>
352     * Returns the value as-is if not ending with the prefix.
353     *
354     * @param value the value
355     * @param suffix the suffix to remove from value
356     * @return the value without the suffix
357     */
358    public static String stripSuffix(final String value, final String suffix) {
359        if (value == null || suffix == null) {
360            return value;
361        }
362
363        if (value.endsWith(suffix)) {
364            return value.substring(0, value.length() - suffix.length());
365        }
366
367        return value;
368    }
369
370    /**
371     * Assembles a query from the given map.
372     *
373     * @param options the map with the options (eg key/value pairs)
374     * @return a query string with <tt>key1=value&key2=value2&...</tt>, or an
375     *         empty string if there is no options.
376     * @throws URISyntaxException is thrown if uri has invalid syntax.
377     */
378    @SuppressWarnings("unchecked")
379    public static String createQueryString(Map<String, Object> options) throws URISyntaxException {
380        try {
381            if (options.size() > 0) {
382                StringBuilder rc = new StringBuilder();
383                boolean first = true;
384                for (Object o : options.keySet()) {
385                    if (first) {
386                        first = false;
387                    } else {
388                        rc.append("&");
389                    }
390
391                    String key = (String)o;
392                    Object value = options.get(key);
393
394                    // the value may be a list since the same key has multiple
395                    // values
396                    if (value instanceof List) {
397                        List<String> list = (List<String>)value;
398                        for (Iterator<String> it = list.iterator(); it.hasNext();) {
399                            String s = it.next();
400                            appendQueryStringParameter(key, s, rc);
401                            // append & separator if there is more in the list
402                            // to append
403                            if (it.hasNext()) {
404                                rc.append("&");
405                            }
406                        }
407                    } else {
408                        // use the value as a String
409                        String s = value != null ? value.toString() : null;
410                        appendQueryStringParameter(key, s, rc);
411                    }
412                }
413                return rc.toString();
414            } else {
415                return "";
416            }
417        } catch (UnsupportedEncodingException e) {
418            URISyntaxException se = new URISyntaxException(e.toString(), "Invalid encoding");
419            se.initCause(e);
420            throw se;
421        }
422    }
423
424    private static void appendQueryStringParameter(String key, String value, StringBuilder rc) throws UnsupportedEncodingException {
425        rc.append(URLEncoder.encode(key, CHARSET));
426        if (value == null) {
427            return;
428        }
429        // only append if value is not null
430        rc.append("=");
431        boolean isRaw = URIScanner.resolveRaw(value, (str, raw) -> {
432            // do not encode RAW parameters unless it has %
433            // need to replace % with %25 to avoid losing "%" when decoding
434            String s = StringHelper.replaceAll(str, "%", "%25");
435            rc.append(s);
436        });
437        if (!isRaw) {
438            rc.append(URLEncoder.encode(value, CHARSET));
439        }
440    }
441
442    /**
443     * Creates a URI from the original URI and the remaining parameters
444     * <p/>
445     * Used by various Camel components
446     */
447    public static URI createRemainingURI(URI originalURI, Map<String, Object> params) throws URISyntaxException {
448        String s = createQueryString(params);
449        if (s.length() == 0) {
450            s = null;
451        }
452        return createURIWithQuery(originalURI, s);
453    }
454
455    /**
456     * Appends the given parameters to the given URI.
457     * <p/>
458     * It keeps the original parameters and if a new parameter is already
459     * defined in {@code originalURI}, it will be replaced by its value in
460     * {@code newParameters}.
461     *
462     * @param originalURI the original URI
463     * @param newParameters the parameters to add
464     * @return the URI with all the parameters
465     * @throws URISyntaxException is thrown if the uri syntax is invalid
466     * @throws UnsupportedEncodingException is thrown if encoding error
467     */
468    public static String appendParametersToURI(String originalURI, Map<String, Object> newParameters) throws URISyntaxException, UnsupportedEncodingException {
469        URI uri = new URI(normalizeUri(originalURI));
470        Map<String, Object> parameters = parseParameters(uri);
471        parameters.putAll(newParameters);
472        return createRemainingURI(uri, parameters).toString();
473    }
474
475    /**
476     * Normalizes the uri by reordering the parameters so they are sorted and
477     * thus we can use the uris for endpoint matching.
478     * <p/>
479     * The URI parameters will by default be URI encoded. However you can define
480     * a parameter values with the syntax: <tt>key=RAW(value)</tt> which tells
481     * Camel to not encode the value, and use the value as is (eg key=value) and
482     * the value has <b>not</b> been encoded.
483     *
484     * @param uri the uri
485     * @return the normalized uri
486     * @throws URISyntaxException in thrown if the uri syntax is invalid
487     * @throws UnsupportedEncodingException is thrown if encoding error
488     * @see #RAW_TOKEN_PREFIX
489     * @see #RAW_TOKEN_START
490     * @see #RAW_TOKEN_END
491     */
492    public static String normalizeUri(String uri) throws URISyntaxException, UnsupportedEncodingException {
493
494        URI u = new URI(UnsafeUriCharactersEncoder.encode(uri, true));
495        String path = u.getSchemeSpecificPart();
496        String scheme = u.getScheme();
497
498        // not possible to normalize
499        if (scheme == null || path == null) {
500            return uri;
501        }
502
503        // lets trim off any query arguments
504        if (path.startsWith("//")) {
505            path = path.substring(2);
506        }
507        int idx = path.indexOf('?');
508        // when the path has ?
509        if (idx != -1) {
510            path = path.substring(0, idx);
511        }
512
513        if (u.getScheme().startsWith("http")) {
514            path = UnsafeUriCharactersEncoder.encodeHttpURI(path);
515        } else {
516            path = UnsafeUriCharactersEncoder.encode(path);
517        }
518
519        // okay if we have user info in the path and they use @ in username or
520        // password,
521        // then we need to encode them (but leave the last @ sign before the
522        // hostname)
523        // this is needed as Camel end users may not encode their user info
524        // properly, but expect
525        // this to work out of the box with Camel, and hence we need to fix it
526        // for them
527        String userInfoPath = path;
528        if (userInfoPath.contains("/")) {
529            userInfoPath = userInfoPath.substring(0, userInfoPath.indexOf("/"));
530        }
531        if (StringHelper.countChar(userInfoPath, '@') > 1) {
532            int max = userInfoPath.lastIndexOf('@');
533            String before = userInfoPath.substring(0, max);
534            // after must be from original path
535            String after = path.substring(max);
536
537            // replace the @ with %40
538            before = StringHelper.replaceAll(before, "@", "%40");
539            path = before + after;
540        }
541
542        // in case there are parameters we should reorder them
543        Map<String, Object> parameters = URISupport.parseParameters(u);
544        if (parameters.isEmpty()) {
545            // no parameters then just return
546            return buildUri(scheme, path, null);
547        } else {
548            // reorder parameters a..z
549            List<String> keys = new ArrayList<>(parameters.keySet());
550            keys.sort(null);
551
552            Map<String, Object> sorted = new LinkedHashMap<>(parameters.size());
553            for (String key : keys) {
554                sorted.put(key, parameters.get(key));
555            }
556
557            // build uri object with sorted parameters
558            String query = URISupport.createQueryString(sorted);
559            return buildUri(scheme, path, query);
560        }
561    }
562
563    private static String buildUri(String scheme, String path, String query) {
564        // must include :// to do a correct URI all components can work with
565        return scheme + "://" + path + (query != null ? "?" + query : "");
566    }
567
568    public static Map<String, Object> extractProperties(Map<String, Object> properties, String optionPrefix) {
569        Map<String, Object> rc = new LinkedHashMap<>(properties.size());
570
571        for (Iterator<Map.Entry<String, Object>> it = properties.entrySet().iterator(); it.hasNext();) {
572            Map.Entry<String, Object> entry = it.next();
573            String name = entry.getKey();
574            if (name.startsWith(optionPrefix)) {
575                Object value = properties.get(name);
576                name = name.substring(optionPrefix.length());
577                rc.put(name, value);
578                it.remove();
579            }
580        }
581
582        return rc;
583    }
584
585    public static String pathAndQueryOf(final URI uri) {
586        final String path = uri.getPath();
587
588        String pathAndQuery = path;
589        if (ObjectHelper.isEmpty(path)) {
590            pathAndQuery = "/";
591        }
592
593        final String query = uri.getQuery();
594        if (ObjectHelper.isNotEmpty(query)) {
595            pathAndQuery += "?" + query;
596        }
597
598        return pathAndQuery;
599    }
600
601    public static String joinPaths(final String... paths) {
602        if (paths == null || paths.length == 0) {
603            return "";
604        }
605
606        final StringBuilder joined = new StringBuilder();
607
608        boolean addedLast = false;
609        for (int i = paths.length - 1; i >= 0; i--) {
610            String path = paths[i];
611            if (ObjectHelper.isNotEmpty(path)) {
612                if (addedLast) {
613                    path = stripSuffix(path, "/");
614                }
615
616                addedLast = true;
617
618                if (path.charAt(0) == '/') {
619                    joined.insert(0, path);
620                } else {
621                    if (i > 0) {
622                        joined.insert(0, '/').insert(1, path);
623                    } else {
624                        joined.insert(0, path);
625                    }
626                }
627            }
628        }
629
630        return joined.toString();
631    }
632}