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 IInterceptorBroadcaster compositeBroadcaster = 278 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequest); 279 280 // TODO: to be removed when pointcut STORAGE_BINARY_ASSIGN_BLOB_ID_PREFIX has exceeded the grace period 281 boolean hasStorageBinaryAssignBlobIdPrefixHooks = 282 compositeBroadcaster.hasHooks(Pointcut.STORAGE_BINARY_ASSIGN_BLOB_ID_PREFIX); 283 284 boolean hasStorageBinaryAssignBinaryContentIdPrefixHooks = 285 compositeBroadcaster.hasHooks(Pointcut.STORAGE_BINARY_ASSIGN_BINARY_CONTENT_ID_PREFIX); 286 287 if (!(hasStorageBinaryAssignBlobIdPrefixHooks || hasStorageBinaryAssignBinaryContentIdPrefixHooks)) { 288 return null; 289 } 290 291 HookParams params = 292 new HookParams().add(RequestDetails.class, theRequest).add(IBaseResource.class, theResource); 293 294 BaseBinaryStorageSvcImpl.setBinaryContentIdPrefixApplied(theRequest); 295 296 Pointcut pointcutToInvoke = Pointcut.STORAGE_BINARY_ASSIGN_BINARY_CONTENT_ID_PREFIX; 297 298 // TODO: to be removed when pointcut STORAGE_BINARY_ASSIGN_BLOB_ID_PREFIX has exceeded the grace period 299 if (hasStorageBinaryAssignBlobIdPrefixHooks) { 300 pointcutToInvoke = Pointcut.STORAGE_BINARY_ASSIGN_BLOB_ID_PREFIX; 301 } 302 303 return (String) compositeBroadcaster.callHooksAndReturnObject(pointcutToInvoke, params); 304 } 305 306 @Nonnull 307 @SuppressWarnings("unchecked") 308 private List<DeferredBinaryTarget> getOrCreateDeferredBinaryStorageList(IBaseResource theResource) { 309 Object deferredBinaryTargetList = theResource.getUserData(getDeferredListKey()); 310 if (deferredBinaryTargetList == null) { 311 deferredBinaryTargetList = new ArrayList<>(); 312 theResource.setUserData(getDeferredListKey(), deferredBinaryTargetList); 313 } 314 return (List<DeferredBinaryTarget>) deferredBinaryTargetList; 315 } 316 317 @SuppressWarnings("unchecked") 318 @Hook(Pointcut.STORAGE_PRECOMMIT_RESOURCE_CREATED) 319 public void storeLargeBinariesBeforeCreatePersistence( 320 TransactionDetails theTransactionDetails, IBaseResource theResource, Pointcut thePointcut) 321 throws IOException { 322 if (theResource == null) { 323 return; 324 } 325 Object deferredBinaryTargetList = theResource.getUserData(getDeferredListKey()); 326 327 if (deferredBinaryTargetList != null) { 328 IIdType resourceId = theResource.getIdElement(); 329 for (DeferredBinaryTarget next : (List<DeferredBinaryTarget>) deferredBinaryTargetList) { 330 String blobId = next.getBlobId(); 331 IBinaryTarget target = next.getBinaryTarget(); 332 InputStream dataStream = next.getDataStream(); 333 String contentType = target.getContentType(); 334 RequestDetails requestDetails = initRequestDetails(next); 335 myBinaryStorageSvc.storeBinaryContent(resourceId, blobId, contentType, dataStream, requestDetails); 336 } 337 } 338 } 339 340 private RequestDetails initRequestDetails(DeferredBinaryTarget theDeferredBinaryTarget) { 341 ServletRequestDetails requestDetails = new ServletRequestDetails(); 342 if (theDeferredBinaryTarget.isBlobIdPrefixHookApplied()) { 343 BaseBinaryStorageSvcImpl.setBinaryContentIdPrefixApplied(requestDetails); 344 } 345 return requestDetails; 346 } 347 348 public String getDeferredListKey() { 349 return myDeferredListKey; 350 } 351 352 @Hook(Pointcut.STORAGE_PRESHOW_RESOURCES) 353 public void preShow(IPreResourceShowDetails theDetails) throws IOException { 354 if (!isAllowAutoInflateBinaries()) { 355 return; 356 } 357 358 long cumulativeInflatedBytes = 0; 359 int inflatedResourceCount = 0; 360 361 for (IBaseResource nextResource : theDetails) { 362 if (nextResource == null) { 363 ourLog.warn( 364 "Received a null resource during STORAGE_PRESHOW_RESOURCES. This is a bug and should be reported. Skipping resource."); 365 continue; 366 } 367 cumulativeInflatedBytes = inflateBinariesInResource(cumulativeInflatedBytes, nextResource); 368 inflatedResourceCount += 1; 369 if (cumulativeInflatedBytes >= myAutoInflateBinariesMaximumBytes) { 370 ourLog.debug( 371 "Exiting binary data inflation early.[byteCount={}, resourcesInflated={}, resourcesSkipped={}]", 372 cumulativeInflatedBytes, 373 inflatedResourceCount, 374 theDetails.size() - inflatedResourceCount); 375 return; 376 } 377 } 378 ourLog.debug( 379 "Exiting binary data inflation having inflated everything.[byteCount={}, resourcesInflated={}, resourcesSkipped=0]", 380 cumulativeInflatedBytes, 381 inflatedResourceCount); 382 } 383 384 private long inflateBinariesInResource(long theCumulativeInflatedBytes, IBaseResource theResource) 385 throws IOException { 386 IIdType resourceId = theResource.getIdElement(); 387 List<IBinaryTarget> attachments = recursivelyScanResourceForBinaryData(theResource); 388 for (IBinaryTarget nextTarget : attachments) { 389 Optional<String> attachmentId = nextTarget.getAttachmentId(); 390 if (attachmentId.isPresent()) { 391 392 StoredDetails blobDetails = 393 myBinaryStorageSvc.fetchBinaryContentDetails(resourceId, attachmentId.get()); 394 if (blobDetails == null) { 395 String msg = myCtx.getLocalizer().getMessage(BinaryAccessProvider.class, "unknownBlobId"); 396 throw new InvalidRequestException(Msg.code(1330) + msg); 397 } 398 399 if ((theCumulativeInflatedBytes + blobDetails.getBytes()) < myAutoInflateBinariesMaximumBytes) { 400 byte[] bytes = myBinaryStorageSvc.fetchBinaryContent(resourceId, attachmentId.get()); 401 nextTarget.setData(bytes); 402 theCumulativeInflatedBytes += blobDetails.getBytes(); 403 } 404 } 405 } 406 return theCumulativeInflatedBytes; 407 } 408 409 @Nonnull 410 private List<IBinaryTarget> recursivelyScanResourceForBinaryData(IBaseResource theResource) { 411 List<IBinaryTarget> binaryTargets = new ArrayList<>(); 412 myCtx.newTerser().visit(theResource, new IModelVisitor2() { 413 @Override 414 public boolean acceptElement( 415 IBase theElement, 416 List<IBase> theContainingElementPath, 417 List<BaseRuntimeChildDefinition> theChildDefinitionPath, 418 List<BaseRuntimeElementDefinition<?>> theElementDefinitionPath) { 419 420 if (theElement.getClass().equals(myBinaryType)) { 421 IBase parent = theContainingElementPath.get(theContainingElementPath.size() - 2); 422 Optional<IBinaryTarget> binaryTarget = myBinaryAccessProvider.toBinaryTarget(parent); 423 binaryTarget.ifPresent(binaryTargets::add); 424 } 425 return true; 426 } 427 }); 428 return binaryTargets; 429 } 430 431 public void setAllowAutoInflateBinaries(boolean theAllowAutoInflateBinaries) { 432 myAllowAutoInflateBinaries = theAllowAutoInflateBinaries; 433 } 434 435 public boolean isAllowAutoInflateBinaries() { 436 return myAllowAutoInflateBinaries; 437 } 438 439 private static class DeferredBinaryTarget { 440 private final String myBlobId; 441 private final IBinaryTarget myBinaryTarget; 442 private final InputStream myDataStream; 443 private boolean myBlobIdPrefixHookApplied; 444 445 private DeferredBinaryTarget(String theBlobId, IBinaryTarget theBinaryTarget, byte[] theData) { 446 myBlobId = theBlobId; 447 myBinaryTarget = theBinaryTarget; 448 myDataStream = new ByteArrayInputStream(theData); 449 } 450 451 String getBlobId() { 452 return myBlobId; 453 } 454 455 IBinaryTarget getBinaryTarget() { 456 return myBinaryTarget; 457 } 458 459 InputStream getDataStream() { 460 return myDataStream; 461 } 462 463 boolean isBlobIdPrefixHookApplied() { 464 return myBlobIdPrefixHookApplied; 465 } 466 467 void setBlobIdPrefixHookApplied(boolean theBlobIdPrefixHookApplied) { 468 myBlobIdPrefixHookApplied = theBlobIdPrefixHookApplied; 469 } 470 } 471}