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     * Assembles a query from the given map.
336     *
337     * @param options  the map with the options (eg key/value pairs)
338     * @return a query string with <tt>key1=value&key2=value2&...</tt>, or an empty string if there is no options.
339     * @throws URISyntaxException is thrown if uri has invalid syntax.
340     */
341    @SuppressWarnings("unchecked")
342    public static String createQueryString(Map<String, Object> options) throws URISyntaxException {
343        try {
344            if (options.size() > 0) {
345                StringBuilder rc = new StringBuilder();
346                boolean first = true;
347                for (Object o : options.keySet()) {
348                    if (first) {
349                        first = false;
350                    } else {
351                        rc.append("&");
352                    }
353
354                    String key = (String) o;
355                    Object value = options.get(key);
356
357                    // the value may be a list since the same key has multiple values
358                    if (value instanceof List) {
359                        List<String> list = (List<String>) value;
360                        for (Iterator<String> it = list.iterator(); it.hasNext();) {
361                            String s = it.next();
362                            appendQueryStringParameter(key, s, rc);
363                            // append & separator if there is more in the list to append
364                            if (it.hasNext()) {
365                                rc.append("&");
366                            }
367                        }
368                    } else {
369                        // use the value as a String
370                        String s = value != null ? value.toString() : null;
371                        appendQueryStringParameter(key, s, rc);
372                    }
373                }
374                return rc.toString();
375            } else {
376                return "";
377            }
378        } catch (UnsupportedEncodingException e) {
379            URISyntaxException se = new URISyntaxException(e.toString(), "Invalid encoding");
380            se.initCause(e);
381            throw se;
382        }
383    }
384
385    private static void appendQueryStringParameter(String key, String value, StringBuilder rc) throws UnsupportedEncodingException {
386        rc.append(URLEncoder.encode(key, CHARSET));
387        if (value == null) {
388            return;
389        }
390        // only append if value is not null
391        rc.append("=");
392        boolean isRaw = URIScanner.resolveRaw(value, (str, raw) -> {
393            // do not encode RAW parameters unless it has %
394            // need to replace % with %25 to avoid losing "%" when decoding
395            String s = StringHelper.replaceAll(str, "%", "%25");
396            rc.append(s);
397        });
398        if (!isRaw) {
399            rc.append(URLEncoder.encode(value, CHARSET));
400        }
401    }
402
403    /**
404     * Creates a URI from the original URI and the remaining parameters
405     * <p/>
406     * Used by various Camel components
407     */
408    public static URI createRemainingURI(URI originalURI, Map<String, Object> params) throws URISyntaxException {
409        String s = createQueryString(params);
410        if (s.length() == 0) {
411            s = null;
412        }
413        return createURIWithQuery(originalURI, s);
414    }
415
416    /**
417     * Appends the given parameters to the given URI.
418     * <p/>
419     * It keeps the original parameters and if a new parameter is already defined in
420     * {@code originalURI}, it will be replaced by its value in {@code newParameters}.
421     *
422     * @param originalURI   the original URI
423     * @param newParameters the parameters to add
424     * @return the URI with all the parameters
425     * @throws URISyntaxException           is thrown if the uri syntax is invalid
426     * @throws UnsupportedEncodingException is thrown if encoding error
427     */
428    public static String appendParametersToURI(String originalURI, Map<String, Object> newParameters) throws URISyntaxException, UnsupportedEncodingException {
429        URI uri = new URI(normalizeUri(originalURI));
430        Map<String, Object> parameters = parseParameters(uri);
431        parameters.putAll(newParameters);
432        return createRemainingURI(uri, parameters).toString();
433    }
434
435    /**
436     * Normalizes the uri by reordering the parameters so they are sorted and thus
437     * we can use the uris for endpoint matching.
438     * <p/>
439     * The URI parameters will by default be URI encoded. However you can define a parameter
440     * values with the syntax: <tt>key=RAW(value)</tt> which tells Camel to not encode the value,
441     * and use the value as is (eg key=value) and the value has <b>not</b> been encoded.
442     *
443     * @param uri the uri
444     * @return the normalized uri
445     * @throws URISyntaxException in thrown if the uri syntax is invalid
446     * @throws UnsupportedEncodingException is thrown if encoding error
447     * @see #RAW_TOKEN_PREFIX
448     * @see #RAW_TOKEN_START
449     * @see #RAW_TOKEN_END
450     */
451    public static String normalizeUri(String uri) throws URISyntaxException, UnsupportedEncodingException {
452
453        URI u = new URI(UnsafeUriCharactersEncoder.encode(uri, true));
454        String path = u.getSchemeSpecificPart();
455        String scheme = u.getScheme();
456
457        // not possible to normalize
458        if (scheme == null || path == null) {
459            return uri;
460        }
461
462        // lets trim off any query arguments
463        if (path.startsWith("//")) {
464            path = path.substring(2);
465        }
466        int idx = path.indexOf('?');
467        // when the path has ?
468        if (idx != -1) {
469            path = path.substring(0, idx);
470        }
471
472        if (u.getScheme().startsWith("http")) {
473            path = UnsafeUriCharactersEncoder.encodeHttpURI(path);
474        } else {
475            path = UnsafeUriCharactersEncoder.encode(path);
476        }
477
478        // okay if we have user info in the path and they use @ in username or password,
479        // then we need to encode them (but leave the last @ sign before the hostname)
480        // this is needed as Camel end users may not encode their user info properly, but expect
481        // this to work out of the box with Camel, and hence we need to fix it for them
482        String userInfoPath = path;
483        if (userInfoPath.contains("/")) {
484            userInfoPath = userInfoPath.substring(0, userInfoPath.indexOf("/"));
485        }
486        if (StringHelper.countChar(userInfoPath, '@') > 1) {
487            int max = userInfoPath.lastIndexOf('@');
488            String before = userInfoPath.substring(0, max);
489            // after must be from original path
490            String after = path.substring(max);
491
492            // replace the @ with %40
493            before = StringHelper.replaceAll(before, "@", "%40");
494            path = before + after;
495        }
496
497        // in case there are parameters we should reorder them
498        Map<String, Object> parameters = URISupport.parseParameters(u);
499        if (parameters.isEmpty()) {
500            // no parameters then just return
501            return buildUri(scheme, path, null);
502        } else {
503            // reorder parameters a..z
504            List<String> keys = new ArrayList<String>(parameters.keySet());
505            keys.sort(null);
506
507            Map<String, Object> sorted = new LinkedHashMap<String, Object>(parameters.size());
508            for (String key : keys) {
509                sorted.put(key, parameters.get(key));
510            }
511
512            // build uri object with sorted parameters
513            String query = URISupport.createQueryString(sorted);
514            return buildUri(scheme, path, query);
515        }
516    }
517
518    private static String buildUri(String scheme, String path, String query) {
519        // must include :// to do a correct URI all components can work with
520        return scheme + "://" + path + (query != null ? "?" + query : "");
521    }
522
523    public static Map<String, Object> extractProperties(Map<String, Object> properties, String optionPrefix) {
524        Map<String, Object> rc = new LinkedHashMap<String, Object>(properties.size());
525
526        for (Iterator<Map.Entry<String, Object>> it = properties.entrySet().iterator(); it.hasNext();) {
527            Map.Entry<String, Object> entry = it.next();
528            String name = entry.getKey();
529            if (name.startsWith(optionPrefix)) {
530                Object value = properties.get(name);
531                name = name.substring(optionPrefix.length());
532                rc.put(name, value);
533                it.remove();
534            }
535        }
536
537        return rc;
538    }
539
540    public static String pathAndQueryOf(final URI uri) {
541        final String path = uri.getPath();
542
543        String pathAndQuery = path;
544        if (ObjectHelper.isEmpty(path)) {
545            pathAndQuery = "/";
546        }
547
548        final String query = uri.getQuery();
549        if (ObjectHelper.isNotEmpty(query)) {
550            pathAndQuery += "?" + query;
551        }
552
553        return pathAndQuery;
554    }
555}