001package ca.uhn.fhir.util;
002
003import ca.uhn.fhir.context.FhirContext;
004import ca.uhn.fhir.context.RuntimeResourceDefinition;
005import ca.uhn.fhir.model.primitive.IdDt;
006import ca.uhn.fhir.parser.DataFormatException;
007import ca.uhn.fhir.rest.api.Constants;
008import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
009import com.google.common.escape.Escaper;
010import com.google.common.net.PercentEscaper;
011import org.hl7.fhir.instance.model.api.IPrimitiveType;
012
013import java.io.UnsupportedEncodingException;
014import java.net.MalformedURLException;
015import java.net.URL;
016import java.net.URLDecoder;
017import java.util.*;
018import java.util.Map.Entry;
019
020import static org.apache.commons.lang3.StringUtils.*;
021
022/*
023 * #%L
024 * HAPI FHIR - Core Library
025 * %%
026 * Copyright (C) 2014 - 2019 University Health Network
027 * %%
028 * Licensed under the Apache License, Version 2.0 (the "License");
029 * you may not use this file except in compliance with the License.
030 * You may obtain a copy of the License at
031 *
032 *      http://www.apache.org/licenses/LICENSE-2.0
033 *
034 * Unless required by applicable law or agreed to in writing, software
035 * distributed under the License is distributed on an "AS IS" BASIS,
036 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
037 * See the License for the specific language governing permissions and
038 * limitations under the License.
039 * #L%
040 */
041
042public class UrlUtil {
043        private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(UrlUtil.class);
044
045        private static final String URL_FORM_PARAMETER_OTHER_SAFE_CHARS = "-_.*";
046        private static final Escaper PARAMETER_ESCAPER = new PercentEscaper(URL_FORM_PARAMETER_OTHER_SAFE_CHARS, false);
047
048        public static class UrlParts {
049                private String myParams;
050                private String myResourceId;
051                private String myResourceType;
052                private String myVersionId;
053
054                public String getParams() {
055                        return myParams;
056                }
057
058                public void setParams(String theParams) {
059                        myParams = theParams;
060                }
061
062                public String getResourceId() {
063                        return myResourceId;
064                }
065
066                public void setResourceId(String theResourceId) {
067                        myResourceId = theResourceId;
068                }
069
070                public String getResourceType() {
071                        return myResourceType;
072                }
073
074                public void setResourceType(String theResourceType) {
075                        myResourceType = theResourceType;
076                }
077
078                public String getVersionId() {
079                        return myVersionId;
080                }
081
082                public void setVersionId(String theVersionId) {
083                        myVersionId = theVersionId;
084                }
085        }
086
087        /**
088         * Resolve a relative URL - THIS METHOD WILL NOT FAIL but will log a warning and return theEndpoint if the input is invalid.
089         */
090        public static String constructAbsoluteUrl(String theBase, String theEndpoint) {
091                if (theEndpoint == null) {
092                        return null;
093                }
094                if (isAbsolute(theEndpoint)) {
095                        return theEndpoint;
096                }
097                if (theBase == null) {
098                        return theEndpoint;
099                }
100
101                try {
102                        return new URL(new URL(theBase), theEndpoint).toString();
103                } catch (MalformedURLException e) {
104                        ourLog.warn("Failed to resolve relative URL[" + theEndpoint + "] against absolute base[" + theBase + "]", e);
105                        return theEndpoint;
106                }
107        }
108
109        public static String constructRelativeUrl(String theParentExtensionUrl, String theExtensionUrl) {
110                if (theParentExtensionUrl == null) {
111                        return theExtensionUrl;
112                }
113                if (theExtensionUrl == null) {
114                        return null;
115                }
116
117                int parentLastSlashIdx = theParentExtensionUrl.lastIndexOf('/');
118                int childLastSlashIdx = theExtensionUrl.lastIndexOf('/');
119
120                if (parentLastSlashIdx == -1 || childLastSlashIdx == -1) {
121                        return theExtensionUrl;
122                }
123
124                if (parentLastSlashIdx != childLastSlashIdx) {
125                        return theExtensionUrl;
126                }
127
128                if (!theParentExtensionUrl.substring(0, parentLastSlashIdx).equals(theExtensionUrl.substring(0, parentLastSlashIdx))) {
129                        return theExtensionUrl;
130                }
131
132                if (theExtensionUrl.length() > parentLastSlashIdx) {
133                        return theExtensionUrl.substring(parentLastSlashIdx + 1);
134                }
135
136                return theExtensionUrl;
137        }
138
139        /**
140         * URL encode a value according to RFC 3986
141         * <p>
142         * This method is intended to be applied to an individual parameter
143         * name or value. For example, if you are creating the URL
144         * <code>http://example.com/fhir/Patient?key=føø</code>
145         * it would be appropriate to pass the string "føø" to this method,
146         * but not appropriate to pass the entire URL since characters
147         * such as "/" and "?" would also be escaped.
148         * </P>
149         */
150        public static String escapeUrlParam(String theUnescaped) {
151                if (theUnescaped == null) {
152                        return null;
153                }
154                return PARAMETER_ESCAPER.escape(theUnescaped);
155        }
156
157        public static boolean isAbsolute(String theValue) {
158                String value = theValue.toLowerCase();
159                return value.startsWith("http://") || value.startsWith("https://");
160        }
161
162        public static boolean isNeedsSanitization(CharSequence theString) {
163                if (theString != null) {
164                        for (int i = 0; i < theString.length(); i++) {
165                                char nextChar = theString.charAt(i);
166                                switch (nextChar) {
167                                        case '\'':
168                                        case '"':
169                                        case '<':
170                                        case '>':
171                                        case '\n':
172                                        case '\r':
173                                                return true;
174                                }
175                                if (nextChar < ' ') {
176                                        return true;
177                                }
178                        }
179                }
180                return false;
181        }
182
183        public static boolean isValid(String theUrl) {
184                if (theUrl == null || theUrl.length() < 8) {
185                        return false;
186                }
187
188                String url = theUrl.toLowerCase();
189                if (url.charAt(0) != 'h') {
190                        return false;
191                }
192                if (url.charAt(1) != 't') {
193                        return false;
194                }
195                if (url.charAt(2) != 't') {
196                        return false;
197                }
198                if (url.charAt(3) != 'p') {
199                        return false;
200                }
201                int slashOffset;
202                if (url.charAt(4) == ':') {
203                        slashOffset = 5;
204                } else if (url.charAt(4) == 's') {
205                        if (url.charAt(5) != ':') {
206                                return false;
207                        }
208                        slashOffset = 6;
209                } else {
210                        return false;
211                }
212
213                if (url.charAt(slashOffset) != '/') {
214                        return false;
215                }
216                if (url.charAt(slashOffset + 1) != '/') {
217                        return false;
218                }
219
220                return true;
221        }
222
223        public static RuntimeResourceDefinition parseUrlResourceType(FhirContext theCtx, String theUrl) throws DataFormatException {
224                int paramIndex = theUrl.indexOf('?');
225                String resourceName = theUrl.substring(0, paramIndex);
226                if (resourceName.contains("/")) {
227                        resourceName = resourceName.substring(resourceName.lastIndexOf('/') + 1);
228                }
229                return theCtx.getResourceDefinition(resourceName);
230        }
231
232        public static Map<String, String[]> parseQueryString(String theQueryString) {
233                HashMap<String, List<String>> map = new HashMap<>();
234                parseQueryString(theQueryString, map);
235                return toQueryStringMap(map);
236        }
237
238        private static void parseQueryString(String theQueryString, HashMap<String, List<String>> map) {
239                String query = defaultString(theQueryString);
240                if (query.startsWith("?")) {
241                        query = query.substring(1);
242                }
243
244
245                StringTokenizer tok = new StringTokenizer(query, "&");
246                while (tok.hasMoreTokens()) {
247                        String nextToken = tok.nextToken();
248                        if (isBlank(nextToken)) {
249                                continue;
250                        }
251
252                        int equalsIndex = nextToken.indexOf('=');
253                        String nextValue;
254                        String nextKey;
255                        if (equalsIndex == -1) {
256                                nextKey = nextToken;
257                                nextValue = "";
258                        } else {
259                                nextKey = nextToken.substring(0, equalsIndex);
260                                nextValue = nextToken.substring(equalsIndex + 1);
261                        }
262
263                        nextKey = unescape(nextKey);
264                        nextValue = unescape(nextValue);
265
266                        List<String> list = map.computeIfAbsent(nextKey, k -> new ArrayList<>());
267                        list.add(nextValue);
268                }
269        }
270
271        public static Map<String, String[]> parseQueryStrings(String... theQueryString) {
272                HashMap<String, List<String>> map = new HashMap<>();
273                for (String next : theQueryString) {
274                        parseQueryString(next, map);
275                }
276                return toQueryStringMap(map);
277        }
278
279        /**
280         * Parse a URL in one of the following forms:
281         * <ul>
282         * <li>[Resource Type]?[Search Params]
283         * <li>[Resource Type]/[Resource ID]
284         * <li>[Resource Type]/[Resource ID]/_history/[Version ID]
285         * </ul>
286         */
287        public static UrlParts parseUrl(String theUrl) {
288                String url = theUrl;
289                UrlParts retVal = new UrlParts();
290                if (url.startsWith("http")) {
291                        if (url.startsWith("/")) {
292                                url = url.substring(1);
293                        }
294
295                        int qmIdx = url.indexOf('?');
296                        if (qmIdx != -1) {
297                                retVal.setParams(defaultIfBlank(url.substring(qmIdx + 1), null));
298                                url = url.substring(0, qmIdx);
299                        }
300
301                        IdDt id = new IdDt(url);
302                        retVal.setResourceType(id.getResourceType());
303                        retVal.setResourceId(id.getIdPart());
304                        retVal.setVersionId(id.getVersionIdPart());
305                        return retVal;
306                }
307                if (url.matches("/[a-zA-Z]+\\?.*")) {
308                        url = url.substring(1);
309                }
310                int nextStart = 0;
311                boolean nextIsHistory = false;
312
313                for (int idx = 0; idx < url.length(); idx++) {
314                        char nextChar = url.charAt(idx);
315                        boolean atEnd = (idx + 1) == url.length();
316                        if (nextChar == '?' || nextChar == '/' || atEnd) {
317                                int endIdx = (atEnd && nextChar != '?') ? idx + 1 : idx;
318                                String nextSubstring = url.substring(nextStart, endIdx);
319                                if (retVal.getResourceType() == null) {
320                                        retVal.setResourceType(nextSubstring);
321                                } else if (retVal.getResourceId() == null) {
322                                        retVal.setResourceId(nextSubstring);
323                                } else if (nextIsHistory) {
324                                        retVal.setVersionId(nextSubstring);
325                                } else {
326                                        if (nextSubstring.equals(Constants.URL_TOKEN_HISTORY)) {
327                                                nextIsHistory = true;
328                                        } else {
329                                                throw new InvalidRequestException("Invalid FHIR resource URL: " + url);
330                                        }
331                                }
332                                if (nextChar == '?') {
333                                        if (url.length() > idx + 1) {
334                                                retVal.setParams(url.substring(idx + 1));
335                                        }
336                                        break;
337                                }
338                                nextStart = idx + 1;
339                        }
340                }
341
342                return retVal;
343
344        }
345
346        /**
347         * This method specifically HTML-encodes the &quot; and
348         * &lt; characters in order to prevent injection attacks
349         */
350        public static String sanitizeUrlPart(IPrimitiveType<?> theString) {
351                String retVal = null;
352                if (theString != null) {
353                        retVal = sanitizeUrlPart(theString.getValueAsString());
354                }
355                return retVal;
356        }
357
358        /**
359         * This method specifically HTML-encodes the &quot; and
360         * &lt; characters in order to prevent injection attacks.
361         *
362         * The following characters are escaped:
363         * <ul>
364         *    <li>&apos;</li>
365         *    <li>&quot;</li>
366         *    <li>&lt;</li>
367         *    <li>&gt;</li>
368         *    <li>\n (newline)</li>
369         * </ul>
370         *
371         */
372        public static String sanitizeUrlPart(CharSequence theString) {
373                if (theString == null) {
374                        return null;
375                }
376
377                boolean needsSanitization = isNeedsSanitization(theString);
378
379                if (needsSanitization) {
380                        // Ok, we're sanitizing
381                        StringBuilder buffer = new StringBuilder(theString.length() + 10);
382                        for (int j = 0; j < theString.length(); j++) {
383
384                                char nextChar = theString.charAt(j);
385                                switch (nextChar) {
386                                        /*
387                                         * NB: If you add a constant here, you also need to add it
388                                         * to isNeedsSanitization()!!
389                                         */
390                                        case '\'':
391                                                buffer.append("&apos;");
392                                                break;
393                                        case '"':
394                                                buffer.append("&quot;");
395                                                break;
396                                        case '<':
397                                                buffer.append("&lt;");
398                                                break;
399                                        case '>':
400                                                buffer.append("&gt;");
401                                                break;
402                                        case '\n':
403                                                buffer.append("&#10;");
404                                                break;
405                                        case '\r':
406                                                buffer.append("&#13;");
407                                                break;
408                                        default:
409                                                if (nextChar >= ' ') {
410                                                        buffer.append(nextChar);
411                                                }
412                                                break;
413                                }
414
415                        } // for build escaped string
416
417                        return buffer.toString();
418                }
419
420                return theString.toString();
421        }
422
423        private static Map<String, String[]> toQueryStringMap(HashMap<String, List<String>> map) {
424                HashMap<String, String[]> retVal = new HashMap<>();
425                for (Entry<String, List<String>> nextEntry : map.entrySet()) {
426                        retVal.put(nextEntry.getKey(), nextEntry.getValue().toArray(new String[0]));
427                }
428                return retVal;
429        }
430
431        public static String unescape(String theString) {
432                if (theString == null) {
433                        return null;
434                }
435                for (int i = 0; i < theString.length(); i++) {
436                        char nextChar = theString.charAt(i);
437                        if (nextChar == '%' || nextChar == '+') {
438                                try {
439                                        // Yes it would be nice to not use a string "UTF-8" but the equivalent
440                                        // method that takes Charset is JDK10+ only... sigh....
441                                        return URLDecoder.decode(theString, "UTF-8");
442                                } catch (UnsupportedEncodingException e) {
443                                        throw new Error("UTF-8 not supported, this shouldn't happen", e);
444                                }
445                        }
446                }
447                return theString;
448        }
449
450}