001/** 002 * Copyright 2015 DuraSpace, Inc. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.fcrepo.http.api; 017 018 019import static com.google.common.collect.Iterators.concat; 020import static com.google.common.collect.Iterators.filter; 021import static com.google.common.collect.Iterators.transform; 022import static com.hp.hpl.jena.rdf.model.ModelFactory.createDefaultModel; 023import static com.hp.hpl.jena.vocabulary.RDF.type; 024import static javax.ws.rs.core.HttpHeaders.CACHE_CONTROL; 025import static javax.ws.rs.core.MediaType.APPLICATION_OCTET_STREAM_TYPE; 026import static javax.ws.rs.core.Response.ok; 027import static javax.ws.rs.core.Response.status; 028import static javax.ws.rs.core.Response.temporaryRedirect; 029import static javax.ws.rs.core.Response.Status.PARTIAL_CONTENT; 030import static javax.ws.rs.core.Response.Status.REQUESTED_RANGE_NOT_SATISFIABLE; 031import static org.apache.commons.lang3.StringUtils.isBlank; 032import static org.apache.jena.riot.RDFLanguages.contentTypeToLang; 033 034import static org.fcrepo.kernel.api.FedoraJcrTypes.LDP_BASIC_CONTAINER; 035import static org.fcrepo.kernel.api.FedoraJcrTypes.LDP_DIRECT_CONTAINER; 036import static org.fcrepo.kernel.api.FedoraJcrTypes.LDP_INDIRECT_CONTAINER; 037import static org.fcrepo.kernel.api.RdfLexicon.BASIC_CONTAINER; 038import static org.fcrepo.kernel.api.RdfLexicon.CONTAINER; 039import static org.fcrepo.kernel.api.RdfLexicon.DIRECT_CONTAINER; 040import static org.fcrepo.kernel.api.RdfLexicon.INDIRECT_CONTAINER; 041import static org.fcrepo.kernel.api.RdfLexicon.LDP_NAMESPACE; 042import static org.fcrepo.kernel.api.RdfLexicon.isManagedNamespace; 043import static org.fcrepo.kernel.modeshape.rdf.ManagedRdf.isManagedTriple; 044 045import java.io.IOException; 046import java.io.InputStream; 047import java.net.URI; 048import java.net.URISyntaxException; 049import java.util.Date; 050import java.util.Iterator; 051import java.util.function.Predicate; 052 053import javax.inject.Inject; 054import javax.jcr.AccessDeniedException; 055import javax.jcr.Binary; 056import javax.jcr.RepositoryException; 057import javax.jcr.Session; 058import javax.servlet.http.HttpServletResponse; 059import javax.ws.rs.BadRequestException; 060import javax.ws.rs.BeanParam; 061import javax.ws.rs.WebApplicationException; 062import javax.ws.rs.core.CacheControl; 063import javax.ws.rs.core.Context; 064import javax.ws.rs.core.EntityTag; 065import javax.ws.rs.core.MediaType; 066import javax.ws.rs.core.Request; 067import javax.ws.rs.core.Response; 068 069import com.fasterxml.jackson.core.JsonParseException; 070import org.apache.jena.atlas.RuntimeIOException; 071import org.apache.jena.riot.RiotException; 072import org.fcrepo.http.commons.api.rdf.HttpTripleUtil; 073import org.fcrepo.http.commons.domain.MultiPrefer; 074import org.fcrepo.http.commons.domain.PreferTag; 075import org.fcrepo.http.commons.domain.Range; 076import org.fcrepo.http.commons.domain.ldp.LdpPreferTag; 077import org.fcrepo.http.commons.responses.RangeRequestInputStream; 078import org.fcrepo.kernel.api.exception.InvalidChecksumException; 079import org.fcrepo.kernel.api.exception.MalformedRdfException; 080import org.fcrepo.kernel.api.exception.RepositoryRuntimeException; 081import org.fcrepo.kernel.api.models.Container; 082import org.fcrepo.kernel.api.models.FedoraBinary; 083import org.fcrepo.kernel.api.models.FedoraResource; 084import org.fcrepo.kernel.api.models.NonRdfSource; 085import org.fcrepo.kernel.api.models.NonRdfSourceDescription; 086import org.fcrepo.kernel.api.services.policy.StoragePolicyDecisionPoint; 087import org.fcrepo.kernel.api.utils.iterators.RdfStream; 088import org.fcrepo.kernel.modeshape.rdf.impl.AclRdfContext; 089import org.fcrepo.kernel.modeshape.rdf.impl.SkolemNodeRdfContext; 090import org.fcrepo.kernel.modeshape.rdf.impl.ChildrenRdfContext; 091import org.fcrepo.kernel.modeshape.rdf.impl.ContentRdfContext; 092import org.fcrepo.kernel.modeshape.rdf.impl.HashRdfContext; 093import org.fcrepo.kernel.modeshape.rdf.impl.LdpContainerRdfContext; 094import org.fcrepo.kernel.modeshape.rdf.impl.LdpIsMemberOfRdfContext; 095import org.fcrepo.kernel.modeshape.rdf.impl.LdpRdfContext; 096import org.fcrepo.kernel.modeshape.rdf.impl.ParentRdfContext; 097import org.fcrepo.kernel.modeshape.rdf.impl.PropertiesRdfContext; 098import org.fcrepo.kernel.modeshape.rdf.impl.ReferencesRdfContext; 099import org.fcrepo.kernel.modeshape.rdf.impl.RootRdfContext; 100import org.fcrepo.kernel.modeshape.rdf.impl.TypeRdfContext; 101import org.fcrepo.kernel.modeshape.services.TransactionServiceImpl; 102 103import org.apache.jena.riot.Lang; 104import org.glassfish.jersey.media.multipart.ContentDisposition; 105import org.jvnet.hk2.annotations.Optional; 106import com.google.common.collect.ImmutableList; 107import com.google.common.collect.Iterators; 108import com.hp.hpl.jena.graph.Triple; 109import com.hp.hpl.jena.rdf.model.Model; 110import com.hp.hpl.jena.rdf.model.Statement; 111 112/** 113 * An abstract class that sits between AbstractResource and any resource that 114 * wishes to share the routines for building responses containing binary 115 * content. 116 * 117 * @author Mike Durbin 118 * @author ajs6f 119 */ 120public abstract class ContentExposingResource extends FedoraBaseResource { 121 122 public static final MediaType MESSAGE_EXTERNAL_BODY = MediaType.valueOf("message/external-body"); 123 124 @Context protected Request request; 125 @Context protected HttpServletResponse servletResponse; 126 127 @Inject 128 @Optional 129 private HttpTripleUtil httpTripleUtil; 130 131 @BeanParam 132 protected MultiPrefer prefer; 133 134 @Inject 135 @Optional 136 StoragePolicyDecisionPoint storagePolicyDecisionPoint; 137 138 protected FedoraResource resource; 139 140 private static final long MAX_BUFFER_SIZE = 10240000; 141 142 private static final Predicate<Triple> IS_MANAGED_TYPE = t -> t.getPredicate().equals(type.asNode()) && 143 isManagedNamespace.test(t.getObject().getNameSpace()); 144 145 protected abstract String externalPath(); 146 147 protected Response getContent(final String rangeValue, 148 final RdfStream rdfStream) throws IOException { 149 if (resource() instanceof FedoraBinary) { 150 151 final String contentTypeString = ((FedoraBinary) resource()).getMimeType(); 152 153 final Lang lang = contentTypeToLang(contentTypeString); 154 155 if (!contentTypeString.equals("text/plain") && lang != null) { 156 157 final String format = lang.getName().toUpperCase(); 158 159 final InputStream content = ((FedoraBinary) resource()).getContent(); 160 161 final Model inputModel = createDefaultModel() 162 .read(content, (resource()).toString(), format); 163 164 rdfStream.concat(Iterators.transform(inputModel.listStatements(), Statement::asTriple)); 165 } else { 166 167 final MediaType mediaType = MediaType.valueOf(contentTypeString); 168 if (MESSAGE_EXTERNAL_BODY.isCompatible(mediaType) 169 && mediaType.getParameters().containsKey("access-type") 170 && mediaType.getParameters().get("access-type").equals("URL") 171 && mediaType.getParameters().containsKey("URL") ) { 172 try { 173 return temporaryRedirect(new URI(mediaType.getParameters().get("URL"))).build(); 174 } catch (final URISyntaxException e) { 175 throw new RepositoryRuntimeException(e); 176 } 177 } 178 return getBinaryContent(rangeValue); 179 } 180 181 } else { 182 rdfStream.concat(getResourceTriples()); 183 if (prefer != null) { 184 prefer.getReturn().addResponseHeaders(servletResponse); 185 } 186 } 187 servletResponse.addHeader("Vary", "Accept, Range, Accept-Encoding, Accept-Language"); 188 189 return ok(rdfStream).build(); 190 } 191 192 protected RdfStream getResourceTriples() { 193 // use the thing described, not the description, for the subject of descriptive triples 194 if (resource() instanceof NonRdfSourceDescription) { 195 resource = ((NonRdfSourceDescription) resource()).getDescribedResource(); 196 } 197 final PreferTag returnPreference; 198 199 if (prefer != null && prefer.hasReturn()) { 200 returnPreference = prefer.getReturn(); 201 } else if (prefer != null && prefer.hasHandling()) { 202 returnPreference = prefer.getHandling(); 203 } else { 204 returnPreference = PreferTag.emptyTag(); 205 } 206 207 final LdpPreferTag ldpPreferences = new LdpPreferTag(returnPreference); 208 209 final RdfStream rdfStream = new RdfStream(); 210 211 final Predicate<Triple> tripleFilter = ldpPreferences.prefersServerManaged() ? x -> true : 212 IS_MANAGED_TYPE.or(isManagedTriple::test).negate(); 213 214 if (ldpPreferences.prefersServerManaged()) { 215 rdfStream.concat(getTriples(LdpRdfContext.class)); 216 } 217 218 rdfStream.concat(filter(getTriples(TypeRdfContext.class), tripleFilter::test)); 219 220 rdfStream.concat(filter(getTriples(PropertiesRdfContext.class), tripleFilter::test)); 221 222 if (!returnPreference.getValue().equals("minimal")) { 223 224 // Additional server-managed triples about this resource 225 if (ldpPreferences.prefersServerManaged()) { 226 rdfStream.concat(getTriples(AclRdfContext.class)); 227 rdfStream.concat(getTriples(RootRdfContext.class)); 228 rdfStream.concat(getTriples(ContentRdfContext.class)); 229 rdfStream.concat(getTriples(ParentRdfContext.class)); 230 } 231 232 // containment triples about this resource 233 if (ldpPreferences.prefersContainment()) { 234 rdfStream.concat(getTriples(ChildrenRdfContext.class)); 235 } 236 237 // LDP container membership triples for this resource 238 if (ldpPreferences.prefersMembership()) { 239 rdfStream.concat(getTriples(LdpContainerRdfContext.class)); 240 rdfStream.concat(getTriples(LdpIsMemberOfRdfContext.class)); 241 } 242 243 // Embed all hash and blank nodes 244 rdfStream.concat(filter(getTriples(HashRdfContext.class), tripleFilter::test)); 245 rdfStream.concat(filter(getTriples(SkolemNodeRdfContext.class), tripleFilter::test)); 246 247 // Include inbound references to this object 248 if (ldpPreferences.prefersReferences()) { 249 rdfStream.concat(getTriples(ReferencesRdfContext.class)); 250 } 251 252 // Embed the children of this object 253 if (ldpPreferences.prefersEmbed()) { 254 255 final Iterator<FedoraResource> children = resource().getChildren(); 256 257 rdfStream.concat(filter(concat(transform(children, child -> 258 child.getTriples(translator(), 259 ImmutableList.of( 260 TypeRdfContext.class, PropertiesRdfContext.class, SkolemNodeRdfContext.class)))), 261 tripleFilter::test)); 262 263 } 264 } 265 266 if (httpTripleUtil != null && ldpPreferences.prefersServerManaged()) { 267 httpTripleUtil.addHttpComponentModelsForResourceToStream(rdfStream, resource(), uriInfo, translator()); 268 } 269 270 271 return rdfStream; 272 } 273 274 /** 275 * Get the binary content of a datastream 276 * 277 * @param rangeValue the range value 278 * @return Binary blob 279 * @throws IOException if io exception occurred 280 */ 281 protected Response getBinaryContent(final String rangeValue) 282 throws IOException { 283 final FedoraBinary binary = (FedoraBinary)resource(); 284 285 // we include an explicit etag, because the default behavior is to use the JCR node's etag, not 286 // the jcr:content node digest. The etag is only included if we are not within a transaction. 287 final String txId = TransactionServiceImpl.getCurrentTransactionId(session()); 288 if (txId == null) { 289 checkCacheControlHeaders(request, servletResponse, binary, session()); 290 } 291 final CacheControl cc = new CacheControl(); 292 cc.setMaxAge(0); 293 cc.setMustRevalidate(true); 294 Response.ResponseBuilder builder; 295 296 if (rangeValue != null && rangeValue.startsWith("bytes")) { 297 298 final Range range = Range.convert(rangeValue); 299 300 final long contentSize = binary.getContentSize(); 301 302 final String endAsString; 303 304 if (range.end() == -1) { 305 endAsString = Long.toString(contentSize - 1); 306 } else { 307 endAsString = Long.toString(range.end()); 308 } 309 310 final String contentRangeValue = 311 String.format("bytes %s-%s/%s", range.start(), 312 endAsString, contentSize); 313 314 if (range.end() > contentSize || 315 (range.end() == -1 && range.start() > contentSize)) { 316 317 builder = status(REQUESTED_RANGE_NOT_SATISFIABLE) 318 .header("Content-Range", contentRangeValue); 319 } else { 320 final long rangeStart = range.start(); 321 final long rangeSize = range.size() == -1 ? contentSize - rangeStart : range.size(); 322 final long remainingBytes = contentSize - rangeStart; 323 final long bufSize = rangeSize < remainingBytes ? rangeSize : remainingBytes; 324 325 if (bufSize < MAX_BUFFER_SIZE) { 326 // Small size range content retrieval use javax.jcr.Binary to improve performance 327 final byte[] buf = new byte[(int) bufSize]; 328 329 final Binary binaryContent = binary.getBinaryContent(); 330 try { 331 binaryContent.read(buf, rangeStart); 332 } catch (final RepositoryException e1) { 333 throw new RepositoryRuntimeException(e1); 334 } 335 binaryContent.dispose(); 336 337 builder = status(PARTIAL_CONTENT).entity(buf) 338 .header("Content-Range", contentRangeValue); 339 } else { 340 // For large range content retrieval, go with the InputStream class to balance 341 // the memory usage, though this is a rare case in range content retrieval. 342 final InputStream content = binary.getContent(); 343 final RangeRequestInputStream rangeInputStream = 344 new RangeRequestInputStream(content, range.start(), range.size()); 345 346 builder = status(PARTIAL_CONTENT).entity(rangeInputStream) 347 .header("Content-Range", contentRangeValue); 348 } 349 } 350 351 } else { 352 final InputStream content = binary.getContent(); 353 builder = ok(content); 354 } 355 356 357 // we set the content-type explicitly to avoid content-negotiation from getting in the way 358 return builder.type(binary.getMimeType()) 359 .cacheControl(cc) 360 .build(); 361 362 } 363 364 protected RdfStream getTriples(final Class<? extends RdfStream> x) { 365 return getTriples(resource(), x); 366 } 367 368 protected RdfStream getTriples(final FedoraResource resource, final Class<? extends RdfStream> x) { 369 return resource.getTriples(translator(), x); 370 } 371 372 protected URI getUri(final FedoraResource resource) { 373 try { 374 final String uri = translator().reverse().convert(resource).getURI(); 375 return new URI(uri); 376 } catch (final URISyntaxException e) { 377 throw new BadRequestException(e); 378 } 379 } 380 381 protected FedoraResource resource() { 382 if (resource == null) { 383 resource = getResourceFromPath(externalPath()); 384 } 385 return resource; 386 } 387 388 389 /** 390 * Add any resource-specific headers to the response 391 * @param resource the resource 392 */ 393 protected void addResourceHttpHeaders(final FedoraResource resource) { 394 if (resource instanceof FedoraBinary) { 395 396 final FedoraBinary binary = (FedoraBinary)resource; 397 final ContentDisposition contentDisposition = ContentDisposition.type("attachment") 398 .fileName(binary.getFilename()) 399 .creationDate(binary.getCreatedDate()) 400 .modificationDate(binary.getLastModifiedDate()) 401 .size(binary.getContentSize()) 402 .build(); 403 404 servletResponse.addHeader("Content-Type", binary.getMimeType()); 405 servletResponse.addHeader("Content-Length", String.valueOf(binary.getContentSize())); 406 servletResponse.addHeader("Accept-Ranges", "bytes"); 407 servletResponse.addHeader("Content-Disposition", contentDisposition.toString()); 408 } 409 410 servletResponse.addHeader("Link", "<" + LDP_NAMESPACE + "Resource>;rel=\"type\""); 411 412 if (resource instanceof NonRdfSource) { 413 servletResponse.addHeader("Link", "<" + LDP_NAMESPACE + "NonRDFSource>;rel=\"type\""); 414 } else if (resource instanceof Container) { 415 servletResponse.addHeader("Link", "<" + CONTAINER.getURI() + ">;rel=\"type\""); 416 if (resource.hasType(LDP_BASIC_CONTAINER)) { 417 servletResponse.addHeader("Link", "<" + BASIC_CONTAINER.getURI() + ">;rel=\"type\""); 418 } else if (resource.hasType(LDP_DIRECT_CONTAINER)) { 419 servletResponse.addHeader("Link", "<" + DIRECT_CONTAINER.getURI() + ">;rel=\"type\""); 420 } else if (resource.hasType(LDP_INDIRECT_CONTAINER)) { 421 servletResponse.addHeader("Link", "<" + INDIRECT_CONTAINER.getURI() + ">;rel=\"type\""); 422 } else { 423 servletResponse.addHeader("Link", "<" + BASIC_CONTAINER.getURI() + ">;rel=\"type\""); 424 } 425 } else { 426 servletResponse.addHeader("Link", "<" + LDP_NAMESPACE + "RDFSource>;rel=\"type\""); 427 } 428 429 } 430 431 /** 432 * Evaluate the cache control headers for the request to see if it can be served from 433 * the cache. 434 * 435 * @param request the request 436 * @param servletResponse the servlet response 437 * @param resource the fedora resource 438 * @param session the session 439 */ 440 protected static void checkCacheControlHeaders(final Request request, 441 final HttpServletResponse servletResponse, 442 final FedoraResource resource, 443 final Session session) { 444 evaluateRequestPreconditions(request, servletResponse, resource, session, true); 445 addCacheControlHeaders(servletResponse, resource, session); 446 } 447 448 /** 449 * Add ETag and Last-Modified cache control headers to the response 450 * @param servletResponse the servlet response 451 * @param resource the fedora resource 452 * @param session the session 453 */ 454 protected static void addCacheControlHeaders(final HttpServletResponse servletResponse, 455 final FedoraResource resource, 456 final Session session) { 457 458 final String txId = TransactionServiceImpl.getCurrentTransactionId(session); 459 if (txId != null) { 460 // Do not add caching headers if in a transaction 461 return; 462 } 463 464 final FedoraResource mutableResource = resource instanceof NonRdfSourceDescription 465 ? ((NonRdfSourceDescription) resource).getDescribedResource() : resource; 466 final EntityTag etag = new EntityTag(mutableResource.getEtagValue()); 467 final Date date = mutableResource.getLastModifiedDate(); 468 469 if (!etag.getValue().isEmpty()) { 470 servletResponse.addHeader("ETag", etag.toString()); 471 } 472 473 if (date != null) { 474 servletResponse.addDateHeader("Last-Modified", date.getTime()); 475 } 476 } 477 478 /** 479 * Evaluate request preconditions to ensure the resource is the expected state 480 * @param request the request 481 * @param servletResponse the servlet response 482 * @param resource the resource 483 * @param session the session 484 */ 485 protected static void evaluateRequestPreconditions(final Request request, 486 final HttpServletResponse servletResponse, 487 final FedoraResource resource, 488 final Session session) { 489 evaluateRequestPreconditions(request, servletResponse, resource, session, false); 490 } 491 492 private static void evaluateRequestPreconditions(final Request request, 493 final HttpServletResponse servletResponse, 494 final FedoraResource resource, 495 final Session session, 496 final boolean cacheControl) { 497 498 final String txId = TransactionServiceImpl.getCurrentTransactionId(session); 499 if (txId != null) { 500 // Force cache revalidation if in a transaction 501 servletResponse.addHeader(CACHE_CONTROL, "must-revalidate"); 502 servletResponse.addHeader(CACHE_CONTROL, "max-age=0"); 503 return; 504 } 505 506 final EntityTag etag = new EntityTag(resource.getEtagValue()); 507 final Date date = resource.getLastModifiedDate(); 508 final Date roundedDate = new Date(); 509 510 if (date != null) { 511 roundedDate.setTime(date.getTime() - date.getTime() % 1000); 512 } 513 514 Response.ResponseBuilder builder = request.evaluatePreconditions(etag); 515 if ( builder != null ) { 516 builder = builder.entity("ETag mismatch"); 517 } else { 518 builder = request.evaluatePreconditions(roundedDate); 519 if ( builder != null ) { 520 builder = builder.entity("Date mismatch"); 521 } 522 } 523 524 if (builder != null && cacheControl ) { 525 final CacheControl cc = new CacheControl(); 526 cc.setMaxAge(0); 527 cc.setMustRevalidate(true); 528 // here we are implicitly emitting a 304 529 // the exception is not an error, it's genuinely 530 // an exceptional condition 531 builder = builder.cacheControl(cc).lastModified(date).tag(etag); 532 } 533 if (builder != null) { 534 throw new WebApplicationException(builder.build()); 535 } 536 } 537 538 protected static MediaType getSimpleContentType(final MediaType requestContentType) { 539 return requestContentType != null ? new MediaType(requestContentType.getType(), requestContentType.getSubtype()) 540 : APPLICATION_OCTET_STREAM_TYPE; 541 } 542 543 protected static boolean isRdfContentType(final String contentTypeString) { 544 return contentTypeToLang(contentTypeString) != null; 545 } 546 547 protected void replaceResourceBinaryWithStream(final FedoraBinary result, 548 final InputStream requestBodyStream, 549 final ContentDisposition contentDisposition, 550 final MediaType contentType, 551 final String checksum) throws InvalidChecksumException { 552 final URI checksumURI = checksumURI(checksum); 553 final String originalFileName = contentDisposition != null ? contentDisposition.getFileName() : ""; 554 final String originalContentType = contentType != null ? contentType.toString() : ""; 555 556 result.setContent(requestBodyStream, 557 originalContentType, 558 checksumURI, 559 originalFileName, 560 storagePolicyDecisionPoint); 561 } 562 563 protected void replaceResourceWithStream(final FedoraResource resource, 564 final InputStream requestBodyStream, 565 final MediaType contentType, 566 final RdfStream resourceTriples) throws MalformedRdfException { 567 final Lang format = contentTypeToLang(contentType.toString()); 568 569 final Model inputModel = createDefaultModel(); 570 try { 571 inputModel.read(requestBodyStream, getUri(resource).toString(), format.getName().toUpperCase()); 572 573 } catch (final RiotException e) { 574 throw new BadRequestException("RDF was not parsable: " + e.getMessage(), e); 575 576 } catch (final RuntimeIOException e) { 577 if (e.getCause() instanceof JsonParseException) { 578 throw new MalformedRdfException(e.getCause()); 579 } 580 throw new RepositoryRuntimeException(e); 581 } 582 583 resource.replaceProperties(translator(), inputModel, resourceTriples); 584 } 585 586 protected void patchResourcewithSparql(final FedoraResource resource, 587 final String requestBody, 588 final RdfStream resourceTriples) 589 throws MalformedRdfException, AccessDeniedException { 590 if (resource instanceof NonRdfSourceDescription) { 591 // update the described resource instead 592 ((NonRdfSourceDescription) resource).getDescribedResource() 593 .updateProperties(translator(), requestBody, resourceTriples); 594 } else { 595 resource.updateProperties(translator(), requestBody, resourceTriples); 596 } 597 } 598 599 /** 600 * Create a checksum URI object. 601 **/ 602 private static URI checksumURI( final String checksum ) { 603 if (!isBlank(checksum)) { 604 return URI.create(checksum); 605 } 606 return null; 607 } 608}