001/*- 002 * #%L 003 * HAPI FHIR Storage api 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.jpa.binary.interceptor; 021 022import ca.uhn.fhir.context.BaseRuntimeChildDefinition; 023import ca.uhn.fhir.context.BaseRuntimeElementDefinition; 024import ca.uhn.fhir.context.FhirContext; 025import ca.uhn.fhir.i18n.Msg; 026import ca.uhn.fhir.interceptor.api.Hook; 027import ca.uhn.fhir.interceptor.api.HookParams; 028import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; 029import ca.uhn.fhir.interceptor.api.Interceptor; 030import ca.uhn.fhir.interceptor.api.Pointcut; 031import ca.uhn.fhir.jpa.binary.api.IBinaryStorageSvc; 032import ca.uhn.fhir.jpa.binary.api.IBinaryTarget; 033import ca.uhn.fhir.jpa.binary.api.StoredDetails; 034import ca.uhn.fhir.jpa.binary.provider.BinaryAccessProvider; 035import ca.uhn.fhir.jpa.binary.svc.BaseBinaryStorageSvcImpl; 036import ca.uhn.fhir.jpa.model.util.JpaConstants; 037import ca.uhn.fhir.rest.api.server.IPreResourceShowDetails; 038import ca.uhn.fhir.rest.api.server.RequestDetails; 039import ca.uhn.fhir.rest.api.server.storage.TransactionDetails; 040import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; 041import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 042import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 043import ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster; 044import ca.uhn.fhir.util.HapiExtensions; 045import ca.uhn.fhir.util.IModelVisitor2; 046import jakarta.annotation.Nonnull; 047import org.apache.commons.io.FileUtils; 048import org.apache.commons.lang3.StringUtils; 049import org.hl7.fhir.instance.model.api.IBase; 050import org.hl7.fhir.instance.model.api.IBaseHasExtensions; 051import org.hl7.fhir.instance.model.api.IBaseResource; 052import org.hl7.fhir.instance.model.api.IIdType; 053import org.hl7.fhir.instance.model.api.IPrimitiveType; 054import org.hl7.fhir.r4.model.IdType; 055import org.slf4j.Logger; 056import org.slf4j.LoggerFactory; 057import org.springframework.beans.factory.annotation.Autowired; 058 059import java.io.ByteArrayInputStream; 060import java.io.IOException; 061import java.io.InputStream; 062import java.util.ArrayList; 063import java.util.HashSet; 064import java.util.List; 065import java.util.Optional; 066import java.util.Set; 067import java.util.concurrent.atomic.AtomicInteger; 068import java.util.stream.Collectors; 069 070import static ca.uhn.fhir.util.HapiExtensions.EXT_EXTERNALIZED_BINARY_ID; 071import static org.apache.commons.lang3.StringUtils.isNotBlank; 072 073@Interceptor 074public class BinaryStorageInterceptor<T extends IPrimitiveType<byte[]>> { 075 076 private static final Logger ourLog = LoggerFactory.getLogger(BinaryStorageInterceptor.class); 077 078 @Autowired 079 private IBinaryStorageSvc myBinaryStorageSvc; 080 081 private final FhirContext myCtx; 082 083 @Autowired 084 private BinaryAccessProvider myBinaryAccessProvider; 085 086 @Autowired 087 private IInterceptorBroadcaster myInterceptorBroadcaster; 088 089 private Class<T> myBinaryType; 090 private String myDeferredListKey; 091 private long myAutoInflateBinariesMaximumBytes = 10 * FileUtils.ONE_MB; 092 private boolean myAllowAutoInflateBinaries = true; 093 094 public BinaryStorageInterceptor(FhirContext theCtx) { 095 myCtx = theCtx; 096 BaseRuntimeElementDefinition<?> base64Binary = myCtx.getElementDefinition("base64Binary"); 097 assert base64Binary != null; 098 myBinaryType = (Class<T>) base64Binary.getImplementingClass(); 099 myDeferredListKey = getClass().getName() + "_" + hashCode() + "_DEFERRED_LIST"; 100 } 101 102 /** 103 * Any externalized binaries will be rehydrated if their size is below this thhreshold when 104 * reading the resource back. Default is 10MB. 105 */ 106 public long getAutoInflateBinariesMaximumSize() { 107 return myAutoInflateBinariesMaximumBytes; 108 } 109 110 /** 111 * Any externalized binaries will be rehydrated if their size is below this thhreshold when 112 * reading the resource back. Default is 10MB. 113 */ 114 public void setAutoInflateBinariesMaximumSize(long theAutoInflateBinariesMaximumBytes) { 115 myAutoInflateBinariesMaximumBytes = theAutoInflateBinariesMaximumBytes; 116 } 117 118 @Hook(Pointcut.STORAGE_PRESTORAGE_EXPUNGE_RESOURCE) 119 public void expungeResource(AtomicInteger theCounter, IBaseResource theResource) { 120 121 List<? extends IBase> binaryElements = 122 myCtx.newTerser().getAllPopulatedChildElementsOfType(theResource, myBinaryType); 123 124 List<String> attachmentIds = binaryElements.stream() 125 .flatMap(t -> ((IBaseHasExtensions) t).getExtension().stream()) 126 .filter(t -> HapiExtensions.EXT_EXTERNALIZED_BINARY_ID.equals(t.getUrl())) 127 .map(t -> ((IPrimitiveType<?>) t.getValue()).getValueAsString()) 128 .collect(Collectors.toList()); 129 130 for (String next : attachmentIds) { 131 myBinaryStorageSvc.expungeBinaryContent(theResource.getIdElement(), next); 132 theCounter.incrementAndGet(); 133 134 ourLog.info( 135 "Deleting binary blob {} because resource {} is being expunged", 136 next, 137 theResource.getIdElement().getValue()); 138 } 139 } 140 141 @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED) 142 public void extractLargeBinariesBeforeCreate( 143 RequestDetails theRequestDetails, 144 TransactionDetails theTransactionDetails, 145 IBaseResource theResource, 146 Pointcut thePointcut) 147 throws IOException { 148 extractLargeBinaries(theRequestDetails, theTransactionDetails, theResource, thePointcut); 149 } 150 151 @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED) 152 public void extractLargeBinariesBeforeUpdate( 153 RequestDetails theRequestDetails, 154 TransactionDetails theTransactionDetails, 155 IBaseResource thePreviousResource, 156 IBaseResource theResource, 157 Pointcut thePointcut) 158 throws IOException { 159 blockIllegalExternalBinaryIds(thePreviousResource, theResource); 160 extractLargeBinaries(theRequestDetails, theTransactionDetails, theResource, thePointcut); 161 } 162 163 /** 164 * Don't allow clients to submit resources with binary storage attachments declared unless the ID was already in the 165 * resource. In other words, only HAPI itself may add a binary storage ID extension to a resource unless that 166 * extension was already present. 167 */ 168 private void blockIllegalExternalBinaryIds(IBaseResource thePreviousResource, IBaseResource theResource) { 169 Set<String> existingBinaryIds = new HashSet<>(); 170 if (thePreviousResource != null) { 171 List<T> base64fields = 172 myCtx.newTerser().getAllPopulatedChildElementsOfType(thePreviousResource, myBinaryType); 173 for (IPrimitiveType<byte[]> nextBase64 : base64fields) { 174 if (nextBase64 instanceof IBaseHasExtensions) { 175 ((IBaseHasExtensions) nextBase64) 176 .getExtension().stream() 177 .filter(t -> t.getUserData(JpaConstants.EXTENSION_EXT_SYSTEMDEFINED) == null) 178 .filter(t -> EXT_EXTERNALIZED_BINARY_ID.equals(t.getUrl())) 179 .map(t -> (IPrimitiveType<?>) t.getValue()) 180 .map(IPrimitiveType::getValueAsString) 181 .filter(StringUtils::isNotBlank) 182 .forEach(existingBinaryIds::add); 183 } 184 } 185 } 186 187 List<T> base64fields = myCtx.newTerser().getAllPopulatedChildElementsOfType(theResource, myBinaryType); 188 for (IPrimitiveType<byte[]> nextBase64 : base64fields) { 189 if (nextBase64 instanceof IBaseHasExtensions) { 190 Optional<String> hasExternalizedBinaryReference = ((IBaseHasExtensions) nextBase64) 191 .getExtension().stream() 192 .filter(t -> t.getUserData(JpaConstants.EXTENSION_EXT_SYSTEMDEFINED) == null) 193 .filter(t -> t.getUrl().equals(EXT_EXTERNALIZED_BINARY_ID)) 194 .map(t -> (IPrimitiveType<?>) t.getValue()) 195 .map(IPrimitiveType::getValueAsString) 196 .filter(StringUtils::isNotBlank) 197 .filter(t -> !existingBinaryIds.contains(t)) 198 .findFirst(); 199 200 if (hasExternalizedBinaryReference.isPresent()) { 201 String msg = myCtx.getLocalizer() 202 .getMessage( 203 BinaryStorageInterceptor.class, 204 "externalizedBinaryStorageExtensionFoundInRequestBody", 205 EXT_EXTERNALIZED_BINARY_ID, 206 hasExternalizedBinaryReference.get()); 207 throw new InvalidRequestException(Msg.code(1329) + msg); 208 } 209 } 210 } 211 } 212 213 private void extractLargeBinaries( 214 RequestDetails theRequestDetails, 215 TransactionDetails theTransactionDetails, 216 IBaseResource theResource, 217 Pointcut thePointcut) 218 throws IOException { 219 220 IIdType resourceId = theResource.getIdElement(); 221 if (!resourceId.hasResourceType() && resourceId.hasIdPart()) { 222 String resourceType = myCtx.getResourceType(theResource); 223 resourceId = new IdType(resourceType + "/" + resourceId.getIdPart()); 224 } 225 226 List<IBinaryTarget> attachments = recursivelyScanResourceForBinaryData(theResource); 227 for (IBinaryTarget nextTarget : attachments) { 228 byte[] data = nextTarget.getData(); 229 if (data != null && data.length > 0) { 230 231 long nextPayloadLength = data.length; 232 String nextContentType = nextTarget.getContentType(); 233 boolean shouldStoreBlob = 234 myBinaryStorageSvc.shouldStoreBinaryContent(nextPayloadLength, resourceId, nextContentType); 235 if (shouldStoreBlob) { 236 237 String newBinaryContentId; 238 if (thePointcut == Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED) { 239 ByteArrayInputStream inputStream = new ByteArrayInputStream(data); 240 StoredDetails storedDetails = myBinaryStorageSvc.storeBinaryContent( 241 resourceId, null, nextContentType, inputStream, theRequestDetails); 242 newBinaryContentId = storedDetails.getBinaryContentId(); 243 } else { 244 assert thePointcut == Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED : thePointcut.name(); 245 newBinaryContentId = myBinaryStorageSvc.newBinaryContentId(); 246 247 String prefix = invokeAssignBinaryContentPrefix(theRequestDetails, theResource); 248 if (isNotBlank(prefix)) { 249 newBinaryContentId = prefix + newBinaryContentId; 250 } 251 if (myBinaryStorageSvc.isValidBinaryContentId(newBinaryContentId)) { 252 List<DeferredBinaryTarget> deferredBinaryTargets = 253 getOrCreateDeferredBinaryStorageList(theResource); 254 DeferredBinaryTarget newDeferredBinaryTarget = 255 new DeferredBinaryTarget(newBinaryContentId, nextTarget, data); 256 deferredBinaryTargets.add(newDeferredBinaryTarget); 257 newDeferredBinaryTarget.setBlobIdPrefixHookApplied(true); 258 } else { 259 throw new InternalErrorException(Msg.code(2341) 260 + "Invalid binaryContent ID for backing storage service.[binaryContentId=" 261 + newBinaryContentId + ",service=" 262 + myBinaryStorageSvc.getClass().getName() + "]"); 263 } 264 } 265 266 myBinaryAccessProvider.replaceDataWithExtension(nextTarget, newBinaryContentId); 267 } 268 } 269 } 270 } 271 272 /** 273 * This invokes the {@link Pointcut#STORAGE_BINARY_ASSIGN_BLOB_ID_PREFIX} hook and returns the prefix to use for the blob ID, or null if there are no implementers. 274 * @return A string, which will be used to prefix the blob ID. May be null. 275 */ 276 private String invokeAssignBinaryContentPrefix(RequestDetails theRequest, IBaseResource theResource) { 277 // TODO: to be removed when pointcut STORAGE_BINARY_ASSIGN_BLOB_ID_PREFIX has exceeded the grace period 278 boolean hasStorageBinaryAssignBlobIdPrefixHooks = CompositeInterceptorBroadcaster.hasHooks( 279 Pointcut.STORAGE_BINARY_ASSIGN_BLOB_ID_PREFIX, myInterceptorBroadcaster, theRequest); 280 281 boolean hasStorageBinaryAssignBinaryContentIdPrefixHooks = CompositeInterceptorBroadcaster.hasHooks( 282 Pointcut.STORAGE_BINARY_ASSIGN_BINARY_CONTENT_ID_PREFIX, myInterceptorBroadcaster, theRequest); 283 284 if (!(hasStorageBinaryAssignBlobIdPrefixHooks || hasStorageBinaryAssignBinaryContentIdPrefixHooks)) { 285 return null; 286 } 287 288 HookParams params = 289 new HookParams().add(RequestDetails.class, theRequest).add(IBaseResource.class, theResource); 290 291 BaseBinaryStorageSvcImpl.setBinaryContentIdPrefixApplied(theRequest); 292 293 Pointcut pointcutToInvoke = Pointcut.STORAGE_BINARY_ASSIGN_BINARY_CONTENT_ID_PREFIX; 294 295 // TODO: to be removed when pointcut STORAGE_BINARY_ASSIGN_BLOB_ID_PREFIX has exceeded the grace period 296 if (hasStorageBinaryAssignBlobIdPrefixHooks) { 297 pointcutToInvoke = Pointcut.STORAGE_BINARY_ASSIGN_BLOB_ID_PREFIX; 298 } 299 300 return (String) CompositeInterceptorBroadcaster.doCallHooksAndReturnObject( 301 myInterceptorBroadcaster, theRequest, pointcutToInvoke, params); 302 } 303 304 @Nonnull 305 @SuppressWarnings("unchecked") 306 private List<DeferredBinaryTarget> getOrCreateDeferredBinaryStorageList(IBaseResource theResource) { 307 Object deferredBinaryTargetList = theResource.getUserData(getDeferredListKey()); 308 if (deferredBinaryTargetList == null) { 309 deferredBinaryTargetList = new ArrayList<>(); 310 theResource.setUserData(getDeferredListKey(), deferredBinaryTargetList); 311 } 312 return (List<DeferredBinaryTarget>) deferredBinaryTargetList; 313 } 314 315 @SuppressWarnings("unchecked") 316 @Hook(Pointcut.STORAGE_PRECOMMIT_RESOURCE_CREATED) 317 public void storeLargeBinariesBeforeCreatePersistence( 318 TransactionDetails theTransactionDetails, IBaseResource theResource, Pointcut thePointcut) 319 throws IOException { 320 if (theResource == null) { 321 return; 322 } 323 Object deferredBinaryTargetList = theResource.getUserData(getDeferredListKey()); 324 325 if (deferredBinaryTargetList != null) { 326 IIdType resourceId = theResource.getIdElement(); 327 for (DeferredBinaryTarget next : (List<DeferredBinaryTarget>) deferredBinaryTargetList) { 328 String blobId = next.getBlobId(); 329 IBinaryTarget target = next.getBinaryTarget(); 330 InputStream dataStream = next.getDataStream(); 331 String contentType = target.getContentType(); 332 RequestDetails requestDetails = initRequestDetails(next); 333 myBinaryStorageSvc.storeBinaryContent(resourceId, blobId, contentType, dataStream, requestDetails); 334 } 335 } 336 } 337 338 private RequestDetails initRequestDetails(DeferredBinaryTarget theDeferredBinaryTarget) { 339 ServletRequestDetails requestDetails = new ServletRequestDetails(); 340 if (theDeferredBinaryTarget.isBlobIdPrefixHookApplied()) { 341 BaseBinaryStorageSvcImpl.setBinaryContentIdPrefixApplied(requestDetails); 342 } 343 return requestDetails; 344 } 345 346 public String getDeferredListKey() { 347 return myDeferredListKey; 348 } 349 350 @Hook(Pointcut.STORAGE_PRESHOW_RESOURCES) 351 public void preShow(IPreResourceShowDetails theDetails) throws IOException { 352 if (!isAllowAutoInflateBinaries()) { 353 return; 354 } 355 356 long cumulativeInflatedBytes = 0; 357 int inflatedResourceCount = 0; 358 359 for (IBaseResource nextResource : theDetails) { 360 if (nextResource == null) { 361 ourLog.warn( 362 "Received a null resource during STORAGE_PRESHOW_RESOURCES. This is a bug and should be reported. Skipping resource."); 363 continue; 364 } 365 cumulativeInflatedBytes = inflateBinariesInResource(cumulativeInflatedBytes, nextResource); 366 inflatedResourceCount += 1; 367 if (cumulativeInflatedBytes >= myAutoInflateBinariesMaximumBytes) { 368 ourLog.debug( 369 "Exiting binary data inflation early.[byteCount={}, resourcesInflated={}, resourcesSkipped={}]", 370 cumulativeInflatedBytes, 371 inflatedResourceCount, 372 theDetails.size() - inflatedResourceCount); 373 return; 374 } 375 } 376 ourLog.debug( 377 "Exiting binary data inflation having inflated everything.[byteCount={}, resourcesInflated={}, resourcesSkipped=0]", 378 cumulativeInflatedBytes, 379 inflatedResourceCount); 380 } 381 382 private long inflateBinariesInResource(long theCumulativeInflatedBytes, IBaseResource theResource) 383 throws IOException { 384 IIdType resourceId = theResource.getIdElement(); 385 List<IBinaryTarget> attachments = recursivelyScanResourceForBinaryData(theResource); 386 for (IBinaryTarget nextTarget : attachments) { 387 Optional<String> attachmentId = nextTarget.getAttachmentId(); 388 if (attachmentId.isPresent()) { 389 390 StoredDetails blobDetails = 391 myBinaryStorageSvc.fetchBinaryContentDetails(resourceId, attachmentId.get()); 392 if (blobDetails == null) { 393 String msg = myCtx.getLocalizer().getMessage(BinaryAccessProvider.class, "unknownBlobId"); 394 throw new InvalidRequestException(Msg.code(1330) + msg); 395 } 396 397 if ((theCumulativeInflatedBytes + blobDetails.getBytes()) < myAutoInflateBinariesMaximumBytes) { 398 byte[] bytes = myBinaryStorageSvc.fetchBinaryContent(resourceId, attachmentId.get()); 399 nextTarget.setData(bytes); 400 theCumulativeInflatedBytes += blobDetails.getBytes(); 401 } 402 } 403 } 404 return theCumulativeInflatedBytes; 405 } 406 407 @Nonnull 408 private List<IBinaryTarget> recursivelyScanResourceForBinaryData(IBaseResource theResource) { 409 List<IBinaryTarget> binaryTargets = new ArrayList<>(); 410 myCtx.newTerser().visit(theResource, new IModelVisitor2() { 411 @Override 412 public boolean acceptElement( 413 IBase theElement, 414 List<IBase> theContainingElementPath, 415 List<BaseRuntimeChildDefinition> theChildDefinitionPath, 416 List<BaseRuntimeElementDefinition<?>> theElementDefinitionPath) { 417 418 if (theElement.getClass().equals(myBinaryType)) { 419 IBase parent = theContainingElementPath.get(theContainingElementPath.size() - 2); 420 Optional<IBinaryTarget> binaryTarget = myBinaryAccessProvider.toBinaryTarget(parent); 421 binaryTarget.ifPresent(binaryTargets::add); 422 } 423 return true; 424 } 425 }); 426 return binaryTargets; 427 } 428 429 public void setAllowAutoInflateBinaries(boolean theAllowAutoInflateBinaries) { 430 myAllowAutoInflateBinaries = theAllowAutoInflateBinaries; 431 } 432 433 public boolean isAllowAutoInflateBinaries() { 434 return myAllowAutoInflateBinaries; 435 } 436 437 private static class DeferredBinaryTarget { 438 private final String myBlobId; 439 private final IBinaryTarget myBinaryTarget; 440 private final InputStream myDataStream; 441 private boolean myBlobIdPrefixHookApplied; 442 443 private DeferredBinaryTarget(String theBlobId, IBinaryTarget theBinaryTarget, byte[] theData) { 444 myBlobId = theBlobId; 445 myBinaryTarget = theBinaryTarget; 446 myDataStream = new ByteArrayInputStream(theData); 447 } 448 449 String getBlobId() { 450 return myBlobId; 451 } 452 453 IBinaryTarget getBinaryTarget() { 454 return myBinaryTarget; 455 } 456 457 InputStream getDataStream() { 458 return myDataStream; 459 } 460 461 boolean isBlobIdPrefixHookApplied() { 462 return myBlobIdPrefixHookApplied; 463 } 464 465 void setBlobIdPrefixHookApplied(boolean theBlobIdPrefixHookApplied) { 466 myBlobIdPrefixHookApplied = theBlobIdPrefixHookApplied; 467 } 468 } 469}