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