001/*
002 * #%L
003 * HAPI FHIR - Core Library
004 * %%
005 * Copyright (C) 2014 - 2024 Smile CDR, Inc.
006 * %%
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 * You may obtain a copy of the License at
010 *
011 *      http://www.apache.org/licenses/LICENSE-2.0
012 *
013 * Unless required by applicable law or agreed to in writing, software
014 * distributed under the License is distributed on an "AS IS" BASIS,
015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
016 * See the License for the specific language governing permissions and
017 * limitations under the License.
018 * #L%
019 */
020package ca.uhn.fhir.util;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.RuntimeResourceDefinition;
024import ca.uhn.fhir.i18n.Msg;
025import ca.uhn.fhir.model.primitive.IdDt;
026import ca.uhn.fhir.parser.DataFormatException;
027import ca.uhn.fhir.rest.api.Constants;
028import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
029import com.google.common.escape.Escaper;
030import com.google.common.net.PercentEscaper;
031import jakarta.annotation.Nonnull;
032import jakarta.annotation.Nullable;
033import org.apache.commons.lang3.StringUtils;
034import org.apache.http.NameValuePair;
035import org.apache.http.client.utils.URLEncodedUtils;
036import org.apache.http.message.BasicNameValuePair;
037import org.hl7.fhir.instance.model.api.IPrimitiveType;
038
039import java.io.UnsupportedEncodingException;
040import java.net.MalformedURLException;
041import java.net.URI;
042import java.net.URISyntaxException;
043import java.net.URL;
044import java.net.URLDecoder;
045import java.nio.file.Path;
046import java.nio.file.Paths;
047import java.util.ArrayList;
048import java.util.Collection;
049import java.util.HashMap;
050import java.util.List;
051import java.util.Map;
052import java.util.Map.Entry;
053import java.util.StringTokenizer;
054import java.util.stream.Collectors;
055
056import static org.apache.commons.lang3.StringUtils.defaultIfBlank;
057import static org.apache.commons.lang3.StringUtils.defaultString;
058import static org.apache.commons.lang3.StringUtils.endsWith;
059import static org.apache.commons.lang3.StringUtils.isBlank;
060import static org.apache.commons.lang3.StringUtils.isNotBlank;
061
062@SuppressWarnings("JavadocLinkAsPlainText")
063public class UrlUtil {
064        private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(UrlUtil.class);
065
066        private static final String URL_FORM_PARAMETER_OTHER_SAFE_CHARS = "-_.*";
067        private static final Escaper PARAMETER_ESCAPER = new PercentEscaper(URL_FORM_PARAMETER_OTHER_SAFE_CHARS, false);
068
069        /**
070         * Non instantiable
071         */
072        private UrlUtil() {}
073
074        /**
075         * Cleans up a value that will be serialized as an HTTP header. This method:
076         * <p>
077         * - Strips any newline (\r or \n) characters
078         *
079         * @since 6.2.0
080         */
081        public static String sanitizeHeaderValue(String theHeader) {
082                return theHeader.replace("\n", "").replace("\r", "");
083        }
084
085        public static String sanitizeBaseUrl(String theBaseUrl) {
086                return theBaseUrl.replaceAll("[^a-zA-Z0-9:/._-]", "");
087        }
088
089        /**
090         * Resolve a relative URL - THIS METHOD WILL NOT FAIL but will log a warning and return theEndpoint if the input is invalid.
091         */
092        public static String constructAbsoluteUrl(String theBase, String theEndpoint) {
093                if (theEndpoint == null) {
094                        return null;
095                }
096                if (isAbsolute(theEndpoint)) {
097                        return theEndpoint;
098                }
099                if (theBase == null) {
100                        return theEndpoint;
101                }
102
103                try {
104                        return new URL(new URL(theBase), theEndpoint).toString();
105                } catch (MalformedURLException e) {
106                        ourLog.warn(
107                                        "Failed to resolve relative URL[" + theEndpoint + "] against absolute base[" + theBase + "]", e);
108                        return theEndpoint;
109                }
110        }
111
112        public static String constructRelativeUrl(String theParentExtensionUrl, String theExtensionUrl) {
113                if (theParentExtensionUrl == null) {
114                        return theExtensionUrl;
115                }
116                if (theExtensionUrl == null) {
117                        return null;
118                }
119
120                int parentLastSlashIdx = theParentExtensionUrl.lastIndexOf('/');
121                int childLastSlashIdx = theExtensionUrl.lastIndexOf('/');
122
123                if (parentLastSlashIdx == -1 || childLastSlashIdx == -1) {
124                        return theExtensionUrl;
125                }
126
127                if (parentLastSlashIdx != childLastSlashIdx) {
128                        return theExtensionUrl;
129                }
130
131                if (!theParentExtensionUrl
132                                .substring(0, parentLastSlashIdx)
133                                .equals(theExtensionUrl.substring(0, parentLastSlashIdx))) {
134                        return theExtensionUrl;
135                }
136
137                if (theExtensionUrl.length() > parentLastSlashIdx) {
138                        return theExtensionUrl.substring(parentLastSlashIdx + 1);
139                }
140
141                return theExtensionUrl;
142        }
143
144        /**
145         * Given a FHIR resource URL, extracts the associated resource type. Supported formats
146         * include the following inputs, all of which will return {@literal Patient}. If no
147         * resource type can be determined, {@literal null} will be returned.
148         * <ul>
149         * <li>Patient
150         * <li>Patient?
151         * <li>Patient?identifier=foo
152         * <li>/Patient
153         * <li>/Patient?
154         * <li>/Patient?identifier=foo
155         * <li>http://foo/base/Patient?identifier=foo
156         * <li>http://foo/base/Patient/1
157         * <li>http://foo/base/Patient/1/_history/2
158         * <li>Patient/1
159         * <li>Patient/1/_history/2
160         * <li>/Patient/1
161         * <li>/Patient/1/_history/2
162         * </ul>
163         */
164        @Nullable
165        public static String determineResourceTypeInResourceUrl(FhirContext theFhirContext, String theUrl) {
166                if (theUrl == null) {
167                        return null;
168                }
169                if (theUrl.startsWith("urn:")) {
170                        return null;
171                }
172
173                String resourceType = null;
174                int qmIndex = theUrl.indexOf("?");
175                if (qmIndex > 0) {
176                        String urlResourceType = theUrl.substring(0, qmIndex);
177                        int slashIdx = urlResourceType.lastIndexOf('/');
178                        if (slashIdx != -1) {
179                                urlResourceType = urlResourceType.substring(slashIdx + 1);
180                        }
181                        if (isNotBlank(urlResourceType)) {
182                                resourceType = urlResourceType;
183                        }
184                } else {
185                        resourceType = theUrl;
186                        int slashIdx = resourceType.indexOf('/');
187                        if (slashIdx == 0) {
188                                resourceType = resourceType.substring(1);
189                        }
190
191                        slashIdx = resourceType.indexOf('/');
192                        if (slashIdx != -1) {
193                                resourceType = new IdDt(resourceType).getResourceType();
194                        }
195                }
196
197                try {
198                        if (isNotBlank(resourceType)) {
199                                theFhirContext.getResourceDefinition(resourceType);
200                        }
201                } catch (DataFormatException e) {
202                        return null;
203                }
204
205                return resourceType;
206        }
207
208        /**
209         * URL encode a value according to RFC 3986
210         * <p>
211         * This method is intended to be applied to an individual parameter
212         * name or value. For example, if you are creating the URL
213         * <code>http://example.com/fhir/Patient?key=føø</code>
214         * it would be appropriate to pass the string "føø" to this method,
215         * but not appropriate to pass the entire URL since characters
216         * such as "/" and "?" would also be escaped.
217         * </P>
218         */
219        public static String escapeUrlParam(String theUnescaped) {
220                if (theUnescaped == null) {
221                        return null;
222                }
223                return PARAMETER_ESCAPER.escape(theUnescaped);
224        }
225
226        /**
227         * Applies the same encodong as {@link #escapeUrlParam(String)} but against all
228         * values in a collection
229         */
230        public static List<String> escapeUrlParams(@Nonnull Collection<String> theUnescaped) {
231                return theUnescaped.stream().map(t -> PARAMETER_ESCAPER.escape(t)).collect(Collectors.toList());
232        }
233
234        public static boolean isAbsolute(String theValue) {
235                String value = theValue.toLowerCase();
236                return value.startsWith("http://") || value.startsWith("https://");
237        }
238
239        public static boolean isNeedsSanitization(CharSequence theString) {
240                if (theString != null) {
241                        for (int i = 0; i < theString.length(); i++) {
242                                char nextChar = theString.charAt(i);
243                                switch (nextChar) {
244                                        case '\'':
245                                        case '"':
246                                        case '<':
247                                        case '>':
248                                        case '\n':
249                                        case '\r':
250                                                return true;
251                                }
252                                if (nextChar < ' ') {
253                                        return true;
254                                }
255                        }
256                }
257                return false;
258        }
259
260        public static boolean isValid(String theUrl) {
261                if (theUrl == null || theUrl.length() < 8) {
262                        return false;
263                }
264
265                String url = theUrl.toLowerCase();
266                if (url.charAt(0) != 'h') {
267                        return false;
268                }
269                if (url.charAt(1) != 't') {
270                        return false;
271                }
272                if (url.charAt(2) != 't') {
273                        return false;
274                }
275                if (url.charAt(3) != 'p') {
276                        return false;
277                }
278                int slashOffset;
279                if (url.charAt(4) == ':') {
280                        slashOffset = 5;
281                } else if (url.charAt(4) == 's') {
282                        if (url.charAt(5) != ':') {
283                                return false;
284                        }
285                        slashOffset = 6;
286                } else {
287                        return false;
288                }
289
290                if (url.charAt(slashOffset) != '/') {
291                        return false;
292                }
293                if (url.charAt(slashOffset + 1) != '/') {
294                        return false;
295                }
296
297                return true;
298        }
299
300        public static RuntimeResourceDefinition parseUrlResourceType(FhirContext theCtx, String theUrl)
301                        throws DataFormatException {
302                String url = theUrl;
303                int paramIndex = url.indexOf('?');
304
305                // Change pattern of "Observation/?param=foo" into "Observation?param=foo"
306                if (paramIndex > 0 && url.charAt(paramIndex - 1) == '/') {
307                        url = url.substring(0, paramIndex - 1) + url.substring(paramIndex);
308                        paramIndex--;
309                }
310
311                String resourceName = url.substring(0, paramIndex);
312                if (resourceName.contains("/")) {
313                        resourceName = resourceName.substring(resourceName.lastIndexOf('/') + 1);
314                }
315                return theCtx.getResourceDefinition(resourceName);
316        }
317
318        public static Map<String, String[]> parseQueryString(String theQueryString) {
319                HashMap<String, List<String>> map = new HashMap<>();
320                parseQueryString(theQueryString, map);
321                return toQueryStringMap(map);
322        }
323
324        private static void parseQueryString(String theQueryString, HashMap<String, List<String>> map) {
325                String query = defaultString(theQueryString);
326                if (query.startsWith("?")) {
327                        query = query.substring(1);
328                }
329
330                StringTokenizer tok = new StringTokenizer(query, "&");
331                while (tok.hasMoreTokens()) {
332                        String nextToken = tok.nextToken();
333                        if (isBlank(nextToken)) {
334                                continue;
335                        }
336
337                        int equalsIndex = nextToken.indexOf('=');
338                        String nextValue;
339                        String nextKey;
340                        if (equalsIndex == -1) {
341                                nextKey = nextToken;
342                                nextValue = "";
343                        } else {
344                                nextKey = nextToken.substring(0, equalsIndex);
345                                nextValue = nextToken.substring(equalsIndex + 1);
346                        }
347
348                        nextKey = unescape(nextKey);
349                        nextValue = unescape(nextValue);
350
351                        List<String> list = map.computeIfAbsent(nextKey, k -> new ArrayList<>());
352                        list.add(nextValue);
353                }
354        }
355
356        public static Map<String, String[]> parseQueryStrings(String... theQueryString) {
357                HashMap<String, List<String>> map = new HashMap<>();
358                for (String next : theQueryString) {
359                        parseQueryString(next, map);
360                }
361                return toQueryStringMap(map);
362        }
363
364        /**
365         * Normalizes canonical URLs for comparison. Trailing "/" is stripped,
366         * and any version identifiers or fragment hash is removed
367         */
368        public static String normalizeCanonicalUrlForComparison(String theUrl) {
369                String retVal;
370                try {
371                        retVal = new URI(theUrl).normalize().toString();
372                } catch (URISyntaxException e) {
373                        retVal = theUrl;
374                }
375                while (endsWith(retVal, "/")) {
376                        retVal = retVal.substring(0, retVal.length() - 1);
377                }
378                int hashOrPipeIndex = StringUtils.indexOfAny(retVal, '#', '|');
379                if (hashOrPipeIndex != -1) {
380                        retVal = retVal.substring(0, hashOrPipeIndex);
381                }
382                return retVal;
383        }
384
385        /**
386         * Parse a URL in one of the following forms:
387         * <ul>
388         * <li>[Resource Type]?[Search Params]
389         * <li>[Resource Type]/[Resource ID]
390         * <li>[Resource Type]/[Resource ID]/_history/[Version ID]
391         * </ul>
392         */
393        public static UrlParts parseUrl(String theUrl) {
394                String url = theUrl;
395                UrlParts retVal = new UrlParts();
396                if (url.startsWith("http")) {
397                        int qmIdx = url.indexOf('?');
398                        if (qmIdx != -1) {
399                                retVal.setParams(defaultIfBlank(url.substring(qmIdx + 1), null));
400                                url = url.substring(0, qmIdx);
401                        }
402
403                        IdDt id = new IdDt(url);
404                        retVal.setResourceType(id.getResourceType());
405                        retVal.setResourceId(id.getIdPart());
406                        retVal.setVersionId(id.getVersionIdPart());
407                        return retVal;
408                }
409
410                int parsingStart = 0;
411                if (url.length() > 2) {
412                        if (url.charAt(0) == '/') {
413                                if (Character.isLetter(url.charAt(1))) {
414                                        parsingStart = 1;
415                                }
416                        }
417                }
418
419                int nextStart = parsingStart;
420                boolean nextIsHistory = false;
421
422                for (int idx = parsingStart; idx < url.length(); idx++) {
423                        char nextChar = url.charAt(idx);
424                        boolean atEnd = (idx + 1) == url.length();
425                        if (nextChar == '?' || nextChar == '/' || atEnd) {
426                                int endIdx = (atEnd && nextChar != '?') ? idx + 1 : idx;
427                                String nextSubstring = url.substring(nextStart, endIdx);
428                                if (retVal.getResourceType() == null) {
429                                        retVal.setResourceType(nextSubstring);
430                                } else if (retVal.getResourceId() == null) {
431                                        retVal.setResourceId(nextSubstring);
432                                } else if (nextIsHistory) {
433                                        retVal.setVersionId(nextSubstring);
434                                } else {
435                                        if (nextSubstring.equals(Constants.URL_TOKEN_HISTORY)) {
436                                                nextIsHistory = true;
437                                        } else {
438                                                throw new InvalidRequestException(Msg.code(1742) + "Invalid FHIR resource URL: " + url);
439                                        }
440                                }
441                                if (nextChar == '?') {
442                                        if (url.length() > idx + 1) {
443                                                retVal.setParams(url.substring(idx + 1));
444                                        }
445                                        break;
446                                }
447                                nextStart = idx + 1;
448                        }
449                }
450
451                return retVal;
452        }
453
454        /**
455         * This method specifically HTML-encodes the &quot; and
456         * &lt; characters in order to prevent injection attacks
457         */
458        public static String sanitizeUrlPart(IPrimitiveType<?> theString) {
459                String retVal = null;
460                if (theString != null) {
461                        retVal = sanitizeUrlPart(theString.getValueAsString());
462                }
463                return retVal;
464        }
465
466        /**
467         * This method specifically HTML-encodes the &quot; and
468         * &lt; characters in order to prevent injection attacks.
469         * <p>
470         * The following characters are escaped:
471         * <ul>
472         *    <li>&apos;</li>
473         *    <li>&quot;</li>
474         *    <li>&lt;</li>
475         *    <li>&gt;</li>
476         *    <li>\n (newline)</li>
477         * </ul>
478         */
479        public static String sanitizeUrlPart(CharSequence theString) {
480                if (theString == null) {
481                        return null;
482                }
483
484                boolean needsSanitization = isNeedsSanitization(theString);
485
486                if (needsSanitization) {
487                        // Ok, we're sanitizing
488                        StringBuilder buffer = new StringBuilder(theString.length() + 10);
489                        for (int j = 0; j < theString.length(); j++) {
490
491                                char nextChar = theString.charAt(j);
492                                switch (nextChar) {
493                                                /*
494                                                 * NB: If you add a constant here, you also need to add it
495                                                 * to isNeedsSanitization()!!
496                                                 */
497                                        case '\'':
498                                                buffer.append("&apos;");
499                                                break;
500                                        case '"':
501                                                buffer.append("&quot;");
502                                                break;
503                                        case '<':
504                                                buffer.append("&lt;");
505                                                break;
506                                        case '>':
507                                                buffer.append("&gt;");
508                                                break;
509                                        case '\n':
510                                                buffer.append("&#10;");
511                                                break;
512                                        case '\r':
513                                                buffer.append("&#13;");
514                                                break;
515                                        default:
516                                                if (nextChar >= ' ') {
517                                                        buffer.append(nextChar);
518                                                }
519                                                break;
520                                }
521                        } // for build escaped string
522
523                        return buffer.toString();
524                }
525
526                return theString.toString();
527        }
528
529        /**
530         * Applies the same logic as {@link #sanitizeUrlPart(CharSequence)} but against an array, returning an array with the
531         * same strings as the input but with sanitization applied
532         */
533        public static String[] sanitizeUrlPart(String[] theParameterValues) {
534                String[] retVal = null;
535                if (theParameterValues != null) {
536                        retVal = new String[theParameterValues.length];
537                        for (int i = 0; i < theParameterValues.length; i++) {
538                                retVal[i] = sanitizeUrlPart(theParameterValues[i]);
539                        }
540                }
541                return retVal;
542        }
543
544        private static Map<String, String[]> toQueryStringMap(HashMap<String, List<String>> map) {
545                HashMap<String, String[]> retVal = new HashMap<>();
546                for (Entry<String, List<String>> nextEntry : map.entrySet()) {
547                        retVal.put(nextEntry.getKey(), nextEntry.getValue().toArray(new String[0]));
548                }
549                return retVal;
550        }
551
552        public static String unescape(String theString) {
553                if (theString == null) {
554                        return null;
555                }
556                // If the user passes "_outputFormat" as a GET request parameter directly in the URL:
557                final boolean shouldEscapePlus = !theString.startsWith("application/");
558
559                for (int i = 0; i < theString.length(); i++) {
560                        char nextChar = theString.charAt(i);
561                        if (nextChar == '%' || (nextChar == '+' && shouldEscapePlus)) {
562                                try {
563                                        // Yes it would be nice to not use a string "UTF-8" but the equivalent
564                                        // method that takes Charset is JDK10+ only... sigh....
565                                        return URLDecoder.decode(theString, "UTF-8");
566                                } catch (UnsupportedEncodingException e) {
567                                        throw new Error(Msg.code(1743) + "UTF-8 not supported, this shouldn't happen", e);
568                                }
569                        }
570                }
571                return theString;
572        }
573
574        public static List<NameValuePair> translateMatchUrl(String theMatchUrl) {
575                List<NameValuePair> parameters;
576                String matchUrl = theMatchUrl;
577                int questionMarkIndex = matchUrl.indexOf('?');
578                if (questionMarkIndex != -1) {
579                        matchUrl = matchUrl.substring(questionMarkIndex + 1);
580                }
581
582                final String[] searchList = new String[] {"|", "=>=", "=<=", "=>", "=<"};
583                final String[] replacementList = new String[] {"%7C", "=%3E%3D", "=%3C%3D", "=%3E", "=%3C"};
584                matchUrl = StringUtils.replaceEach(matchUrl, searchList, replacementList);
585                if (matchUrl.contains(" ")) {
586                        throw new InvalidRequestException(Msg.code(1744) + "Failed to parse match URL[" + theMatchUrl
587                                        + "] - URL is invalid (must not contain spaces)");
588                }
589
590                parameters = URLEncodedUtils.parse((matchUrl), Constants.CHARSET_UTF8, '&');
591
592                // One issue that has happened before is people putting a "+" sign into an email address in a match URL
593                // and having that turn into a " ". Since spaces are never appropriate for email addresses, let's just
594                // assume they really meant "+".
595                for (int i = 0; i < parameters.size(); i++) {
596                        NameValuePair next = parameters.get(i);
597                        if (next.getName().equals("email") && next.getValue().contains(" ")) {
598                                BasicNameValuePair newPair =
599                                                new BasicNameValuePair(next.getName(), next.getValue().replace(' ', '+'));
600                                parameters.set(i, newPair);
601                        }
602                }
603
604                return parameters;
605        }
606
607        /**
608         * Creates list of sub URIs candidates for search with :above modifier
609         * Example input: http://[host]/[pathPart1]/[pathPart2]
610         * Example output: http://[host], http://[host]/[pathPart1], http://[host]/[pathPart1]/[pathPart2]
611         *
612         * @param theUri String URI parameter
613         * @return List of URI candidates
614         */
615        public static List<String> getAboveUriCandidates(String theUri) {
616                try {
617                        URI uri = new URI(theUri);
618                        if (uri.getScheme() == null || uri.getHost() == null) {
619                                throwInvalidRequestExceptionForNotValidUri(theUri, null);
620                        }
621                } catch (URISyntaxException theCause) {
622                        throwInvalidRequestExceptionForNotValidUri(theUri, theCause);
623                }
624
625                List<String> candidates = new ArrayList<>();
626                Path path = Paths.get(theUri);
627                candidates.add(path.toString().replace(":/", "://"));
628                while (path.getParent() != null && path.getParent().toString().contains("/")) {
629                        candidates.add(path.getParent().toString().replace(":/", "://"));
630                        path = path.getParent();
631                }
632                return candidates;
633        }
634
635        private static void throwInvalidRequestExceptionForNotValidUri(String theUri, Exception theCause) {
636                throw new InvalidRequestException(
637                                Msg.code(2419) + String.format("Provided URI is not valid: %s", theUri), theCause);
638        }
639
640        public static class UrlParts {
641                private String myParams;
642                private String myResourceId;
643                private String myResourceType;
644                private String myVersionId;
645
646                public String getParams() {
647                        return myParams;
648                }
649
650                public void setParams(String theParams) {
651                        myParams = theParams;
652                }
653
654                public String getResourceId() {
655                        return myResourceId;
656                }
657
658                public void setResourceId(String theResourceId) {
659                        myResourceId = theResourceId;
660                }
661
662                public String getResourceType() {
663                        return myResourceType;
664                }
665
666                public void setResourceType(String theResourceType) {
667                        myResourceType = theResourceType;
668                }
669
670                public String getVersionId() {
671                        return myVersionId;
672                }
673
674                public void setVersionId(String theVersionId) {
675                        myVersionId = theVersionId;
676                }
677        }
678}