45
45
import ca .uhn .fhir .util .IModelVisitor2 ;
46
46
import jakarta .annotation .Nonnull ;
47
47
import org .apache .commons .io .FileUtils ;
48
- import org .apache .commons .lang3 .StringUtils ;
49
48
import org .hl7 .fhir .instance .model .api .IBase ;
49
+ import org .hl7 .fhir .instance .model .api .IBaseExtension ;
50
50
import org .hl7 .fhir .instance .model .api .IBaseHasExtensions ;
51
51
import org .hl7 .fhir .instance .model .api .IBaseResource ;
52
52
import org .hl7 .fhir .instance .model .api .IIdType ;
60
60
import java .io .IOException ;
61
61
import java .io .InputStream ;
62
62
import java .util .ArrayList ;
63
- import java .util .HashSet ;
63
+ import java .util .Collection ;
64
+ import java .util .HashMap ;
64
65
import java .util .List ;
66
+ import java .util .Map ;
67
+ import java .util .Objects ;
65
68
import java .util .Optional ;
66
69
import java .util .Set ;
67
70
import java .util .concurrent .atomic .AtomicInteger ;
71
+ import java .util .function .Predicate ;
68
72
import java .util .stream .Collectors ;
69
73
74
+ import static ca .uhn .fhir .util .HapiExtensions .EXT_EXTERNALIZED_BINARY_HASH_SHA_256 ;
70
75
import static ca .uhn .fhir .util .HapiExtensions .EXT_EXTERNALIZED_BINARY_ID ;
71
76
import static org .apache .commons .lang3 .StringUtils .isNotBlank ;
72
77
73
78
@ Interceptor
74
79
public class BinaryStorageInterceptor <T extends IPrimitiveType <byte []>> {
75
80
76
81
private static final Logger ourLog = LoggerFactory .getLogger (BinaryStorageInterceptor .class );
82
+ /**
83
+ * UserData key that can be set in {@link RequestDetails#getUserData()} to override
84
+ * the {@link #isAllowAutoInflateBinaries()} setting for a single request.
85
+ * <p>
86
+ * Possible values:
87
+ * <ul>
88
+ * <li>{@code Boolean.TRUE} – force binary inflation even if globally disabled</li>
89
+ * <li>{@code Boolean.FALSE} – skip binary inflation even if globally enabled</li>
90
+ * <li>Absent – use the global {@code isAllowAutoInflateBinaries()} setting</li>
91
+ * </ul>
92
+ */
93
+ public static final String AUTO_INFLATE_BINARY_CONTENT_KEY =
94
+ BinaryStorageInterceptor .class .getName () + "_AUTO_INFLATE_BINARY_CONTENT" ;
95
+
96
+ private static final Predicate <IBaseExtension <?, ?>> EXTENSION_FILTER_PREDICATE =
97
+ ext -> ext .getUserData (JpaConstants .EXTENSION_EXT_SYSTEMDEFINED ) == null ;
77
98
78
99
@ Autowired
79
100
private IBinaryStorageSvc myBinaryStorageSvc ;
@@ -145,7 +166,7 @@ public void extractLargeBinariesBeforeCreate(
145
166
IBaseResource theResource ,
146
167
Pointcut thePointcut )
147
168
throws IOException {
148
- extractLargeBinaries (theRequestDetails , theTransactionDetails , theResource , thePointcut );
169
+ extractLargeBinaries (theRequestDetails , theTransactionDetails , theResource , null , thePointcut );
149
170
}
150
171
151
172
@ Hook (Pointcut .STORAGE_PRESTORAGE_RESOURCE_UPDATED )
@@ -156,64 +177,60 @@ public void extractLargeBinariesBeforeUpdate(
156
177
IBaseResource theResource ,
157
178
Pointcut thePointcut )
158
179
throws IOException {
159
- blockIllegalExternalBinaryIds (thePreviousResource , theResource );
160
- extractLargeBinaries (theRequestDetails , theTransactionDetails , theResource , thePointcut );
180
+ blockIllegalExternalExtensions (thePreviousResource , theResource );
181
+ extractLargeBinaries (theRequestDetails , theTransactionDetails , theResource , thePreviousResource , thePointcut );
161
182
}
162
183
163
184
/**
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 .
185
+ * Prevents clients from submitting resources with binary storage ID or binary storage hash extensions,
186
+ * unless those extensions were already present in the existing resource. In other words, only HAPI itself
187
+ * may add these extensions to a resource .
167
188
*/
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
- }
189
+ private void blockIllegalExternalExtensions (IBaseResource thePreviousResource , IBaseResource theResource ) {
190
+ Map <String , String > existingAttachmentIdToHashMap = buildHashAttachmentIdMap (thePreviousResource , true );
191
+ Set <String > existingBinaryIds = existingAttachmentIdToHashMap .keySet ();
192
+ Collection <String > existingHashes = existingAttachmentIdToHashMap .values ();
193
+
194
+ recursivelyScanResourceForBinaryData (theResource ).forEach (target -> {
195
+ String id =
196
+ target .getAttachmentIdFiltered (EXTENSION_FILTER_PREDICATE ).orElse (null );
197
+ String hash =
198
+ target .getHashExtensionFiltered (EXTENSION_FILTER_PREDICATE ).orElse (null );
199
+
200
+ // binaryId check
201
+ if (id != null && !existingBinaryIds .contains (id )) {
202
+ throwIllegalExtension (EXT_EXTERNALIZED_BINARY_ID , id );
203
+ }
204
+ // hash check
205
+ if (hash != null && !existingHashes .contains (hash )) {
206
+ throwIllegalExtension (EXT_EXTERNALIZED_BINARY_HASH_SHA_256 , hash );
184
207
}
185
- }
186
208
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 );
209
+ // binaryId - hash pair consistency check
210
+ if (id != null && hash != null ) {
211
+ String expected = existingAttachmentIdToHashMap .get (id );
212
+ if (!Objects .equals (expected , hash )) {
213
+ throwIllegalExtension (EXT_EXTERNALIZED_BINARY_HASH_SHA_256 , hash );
208
214
}
209
215
}
210
- }
216
+ });
217
+ }
218
+
219
+ private void throwIllegalExtension (String theExtensionUrl , String theValue ) {
220
+ String msg = myCtx .getLocalizer ()
221
+ .getMessage (
222
+ BinaryStorageInterceptor .class ,
223
+ "externalizedBinaryStorageExtensionFoundInRequestBody" ,
224
+ theExtensionUrl ,
225
+ theValue );
226
+ throw new InvalidRequestException (Msg .code (1329 ) + msg );
211
227
}
212
228
213
229
private void extractLargeBinaries (
214
230
RequestDetails theRequestDetails ,
215
231
TransactionDetails theTransactionDetails ,
216
232
IBaseResource theResource ,
233
+ IBaseResource thePreviousResource ,
217
234
Pointcut thePointcut )
218
235
throws IOException {
219
236
@@ -223,6 +240,7 @@ private void extractLargeBinaries(
223
240
resourceId = new IdType (resourceType + "/" + resourceId .getIdPart ());
224
241
}
225
242
243
+ Map <String , String > existingHashToAttachmentId = buildHashAttachmentIdMap (thePreviousResource , false );
226
244
List <IBinaryTarget > attachments = recursivelyScanResourceForBinaryData (theResource );
227
245
for (IBinaryTarget nextTarget : attachments ) {
228
246
byte [] data = nextTarget .getData ();
@@ -233,13 +251,16 @@ private void extractLargeBinaries(
233
251
boolean shouldStoreBlob =
234
252
myBinaryStorageSvc .shouldStoreBinaryContent (nextPayloadLength , resourceId , nextContentType );
235
253
if (shouldStoreBlob ) {
236
-
254
+ String binaryContentHash = myBinaryAccessProvider . getBinaryContentHash ( data );
237
255
String newBinaryContentId ;
238
256
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 ();
257
+ newBinaryContentId = storeBinaryContentIfRequired (
258
+ theRequestDetails ,
259
+ existingHashToAttachmentId ,
260
+ binaryContentHash ,
261
+ data ,
262
+ resourceId ,
263
+ nextContentType );
243
264
} else {
244
265
assert thePointcut == Pointcut .STORAGE_PRESTORAGE_RESOURCE_CREATED : thePointcut .name ();
245
266
newBinaryContentId = myBinaryStorageSvc .newBinaryContentId ();
@@ -264,11 +285,63 @@ private void extractLargeBinaries(
264
285
}
265
286
266
287
myBinaryAccessProvider .replaceDataWithExtension (nextTarget , newBinaryContentId );
288
+ myBinaryAccessProvider .addHashExtension (nextTarget , binaryContentHash );
267
289
}
268
290
}
269
291
}
270
292
}
271
293
294
+ /**
295
+ * Builds a map of SHA-256 hashes to corresponding attachment IDs from the given FHIR resource.
296
+ * @return A {@link Map} with keys as SHA-256 binary content hashes and values as attachment IDs.
297
+ */
298
+ private Map <String , String > buildHashAttachmentIdMap (
299
+ IBaseResource thePreviousResource , boolean isAttachmentIdToHash ) {
300
+ Map <String , String > result = new HashMap <>();
301
+
302
+ if (thePreviousResource == null ) {
303
+ return result ;
304
+ }
305
+
306
+ List <IBinaryTarget > previousAttachments = recursivelyScanResourceForBinaryData (thePreviousResource );
307
+ for (IBinaryTarget attachment : previousAttachments ) {
308
+ Optional <String > hashOpt = attachment .getHashExtensionFiltered (EXTENSION_FILTER_PREDICATE );
309
+ Optional <String > idOpt = attachment .getAttachmentIdFiltered (EXTENSION_FILTER_PREDICATE );
310
+
311
+ if (isAttachmentIdToHash ) {
312
+ idOpt .ifPresent (id -> result .put (id , hashOpt .orElse (null )));
313
+ } else {
314
+ hashOpt .ifPresent (hash -> result .put (hash , idOpt .orElse (null )));
315
+ }
316
+ }
317
+ return result ;
318
+ }
319
+
320
+ /**
321
+ * This method checks if the given binary content (based on its SHA-256 hash) is already stored in previous
322
+ * resource version. If it is, it reuses the existing attachment ID to avoid saving the same content again.
323
+ * If it's not found, it stores the new content and returns the newly generated attachment ID.
324
+ */
325
+ private String storeBinaryContentIfRequired (
326
+ RequestDetails theRequestDetails ,
327
+ Map <String , String > existingHashToAttachmentId ,
328
+ String binaryContentHash ,
329
+ byte [] data ,
330
+ IIdType resourceId ,
331
+ String nextContentType )
332
+ throws IOException {
333
+ if (existingHashToAttachmentId .get (binaryContentHash ) != null ) {
334
+ // input binary content is the same as existing binary content, reuse existing binaryId
335
+ return existingHashToAttachmentId .get (binaryContentHash );
336
+ } else {
337
+ // there is no existing binary content or content is different, store new content in binary storage
338
+ ByteArrayInputStream inputStream = new ByteArrayInputStream (data );
339
+ StoredDetails storedDetails = myBinaryStorageSvc .storeBinaryContent (
340
+ resourceId , null , nextContentType , inputStream , theRequestDetails );
341
+ return storedDetails .getBinaryContentId ();
342
+ }
343
+ }
344
+
272
345
/**
273
346
* 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
347
* @return A string, which will be used to prefix the blob ID. May be null.
@@ -350,8 +423,14 @@ public String getDeferredListKey() {
350
423
}
351
424
352
425
@ Hook (Pointcut .STORAGE_PRESHOW_RESOURCES )
353
- public void preShow (IPreResourceShowDetails theDetails ) throws IOException {
354
- if (!isAllowAutoInflateBinaries ()) {
426
+ public void preShow (IPreResourceShowDetails theDetails , RequestDetails theRequestDetails ) throws IOException {
427
+ boolean isAllowAutoInflateBinaries = isAllowAutoInflateBinaries ();
428
+ // Override isAllowAutoInflateBinaries setting if AUTO_INFLATE_BINARY_CONTENT flag is present in userData
429
+ if (theRequestDetails != null && theRequestDetails .getUserData ().containsKey (AUTO_INFLATE_BINARY_CONTENT_KEY )) {
430
+ isAllowAutoInflateBinaries =
431
+ Boolean .TRUE .equals (theRequestDetails .getUserData ().get (AUTO_INFLATE_BINARY_CONTENT_KEY ));
432
+ }
433
+ if (!isAllowAutoInflateBinaries ) {
355
434
return ;
356
435
}
357
436
0 commit comments