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 " and 348 * < 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 " and 360 * < characters in order to prevent injection attacks. 361 * 362 * The following characters are escaped: 363 * <ul> 364 * <li>'</li> 365 * <li>"</li> 366 * <li><</li> 367 * <li>></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("'"); 392 break; 393 case '"': 394 buffer.append("""); 395 break; 396 case '<': 397 buffer.append("<"); 398 break; 399 case '>': 400 buffer.append(">"); 401 break; 402 case '\n': 403 buffer.append(" "); 404 break; 405 case '\r': 406 buffer.append(" "); 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}