001/* 002 * (C) Copyright 2015-2018 Nuxeo (http://nuxeo.com/) and others. 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 * 016 * Contributors: 017 * Florent Guillaume 018 * Estelle Giuly <egiuly@nuxeo.com> 019 */ 020package org.nuxeo.ecm.core.io.download; 021 022import java.io.File; 023import java.io.IOException; 024import java.io.InputStream; 025import java.io.OutputStream; 026import java.io.Serializable; 027import java.io.UncheckedIOException; 028import java.io.UnsupportedEncodingException; 029import java.net.URI; 030import java.net.URLEncoder; 031import java.util.Calendar; 032import java.util.Collections; 033import java.util.Enumeration; 034import java.util.HashMap; 035import java.util.HashSet; 036import java.util.List; 037import java.util.Map; 038import java.util.Set; 039import java.util.UUID; 040import java.util.function.Consumer; 041import java.util.function.Supplier; 042import java.util.regex.Pattern; 043 044import javax.script.Invocable; 045import javax.script.ScriptContext; 046import javax.script.ScriptEngine; 047import javax.script.ScriptEngineManager; 048import javax.script.ScriptException; 049import javax.servlet.http.HttpServletRequest; 050import javax.servlet.http.HttpServletResponse; 051 052import org.apache.commons.codec.DecoderException; 053import org.apache.commons.codec.binary.Base64; 054import org.apache.commons.codec.binary.Hex; 055import org.apache.commons.codec.digest.DigestUtils; 056import org.apache.commons.io.IOUtils; 057import org.apache.commons.lang3.StringUtils; 058import org.apache.commons.lang3.tuple.Pair; 059import org.apache.logging.log4j.LogManager; 060import org.apache.logging.log4j.Logger; 061import org.nuxeo.common.utils.URIUtils; 062import org.nuxeo.ecm.core.api.Blob; 063import org.nuxeo.ecm.core.api.CoreInstance; 064import org.nuxeo.ecm.core.api.CoreSession; 065import org.nuxeo.ecm.core.api.DocumentModel; 066import org.nuxeo.ecm.core.api.DocumentRef; 067import org.nuxeo.ecm.core.api.DocumentSecurityException; 068import org.nuxeo.ecm.core.api.IdRef; 069import org.nuxeo.ecm.core.api.NuxeoException; 070import org.nuxeo.ecm.core.api.NuxeoPrincipal; 071import org.nuxeo.ecm.core.api.blobholder.BlobHolder; 072import org.nuxeo.ecm.core.api.blobholder.BlobHolderAdapterService; 073import org.nuxeo.ecm.core.api.event.CoreEventConstants; 074import org.nuxeo.ecm.core.api.impl.blob.AsyncBlob; 075import org.nuxeo.ecm.core.api.model.PropertyNotFoundException; 076import org.nuxeo.ecm.core.blob.BlobManager; 077import org.nuxeo.ecm.core.blob.BlobManager.UsageHint; 078import org.nuxeo.ecm.core.blob.BlobProvider; 079import org.nuxeo.ecm.core.blob.ByteRange; 080import org.nuxeo.ecm.core.blob.LocalBlobProvider; 081import org.nuxeo.ecm.core.blob.ManagedBlob; 082import org.nuxeo.ecm.core.blob.binary.DefaultBinaryManager; 083import org.nuxeo.ecm.core.event.Event; 084import org.nuxeo.ecm.core.event.EventContext; 085import org.nuxeo.ecm.core.event.EventService; 086import org.nuxeo.ecm.core.event.impl.DocumentEventContext; 087import org.nuxeo.ecm.core.event.impl.EventContextImpl; 088import org.nuxeo.ecm.core.io.NginxConstants; 089import org.nuxeo.ecm.core.transientstore.api.TransientStore; 090import org.nuxeo.ecm.core.transientstore.api.TransientStoreService; 091import org.nuxeo.runtime.api.Framework; 092import org.nuxeo.runtime.model.ComponentContext; 093import org.nuxeo.runtime.model.DefaultComponent; 094import org.nuxeo.runtime.services.config.ConfigurationService; 095import org.nuxeo.runtime.transaction.TransactionHelper; 096 097/** 098 * This service allows the download of blobs to a HTTP response. 099 * 100 * @since 7.3 101 */ 102public class DownloadServiceImpl extends DefaultComponent implements DownloadService { 103 104 private static final Logger log = LogManager.getLogger(DownloadServiceImpl.class); 105 106 public static final String XP_PERMISSIONS = "permissions"; 107 108 public static final String XP_REDIRECT_RESOLVER = "redirectResolver"; 109 110 protected static final int DOWNLOAD_BUFFER_SIZE = 1024 * 512; 111 112 private static final String NUXEO_VIRTUAL_HOST = "nuxeo-virtual-host"; 113 114 private static final String VH_PARAM = "nuxeo.virtual.host"; 115 116 private static final String FORCE_NO_CACHE_ON_MSIE = "org.nuxeo.download.force.nocache.msie"; 117 118 /** @since 11.1 */ 119 public static final String DOWNLOAD_URL_FOLLOW_REDIRECT = "org.nuxeo.download.url.follow.redirect"; 120 121 private static final String RUN_FUNCTION = "run"; 122 123 private static final Pattern FILENAME_SANITIZATION_REGEX = Pattern.compile(";\\w+=.*"); 124 125 private static final String DC_MODIFIED = "dc:modified"; 126 127 private static final String MD5 = "MD5"; 128 129 protected enum Action { 130 DOWNLOAD, DOWNLOAD_FROM_DOC, INFO, BLOBSTATUS 131 } 132 133 protected ScriptEngineManager scriptEngineManager = new ScriptEngineManager(); 134 135 protected RedirectResolver redirectResolver; 136 137 @Override 138 public void start(ComponentContext context) { 139 super.start(context); 140 List<RedirectResolverDescriptor> descriptors = getDescriptors(XP_REDIRECT_RESOLVER); 141 if (!descriptors.isEmpty()) { 142 RedirectResolverDescriptor descriptor = descriptors.get(descriptors.size() - 1); 143 try { 144 redirectResolver = descriptor.klass.getDeclaredConstructor().newInstance(); 145 } catch (ReflectiveOperationException e) { 146 log.error("Unable to instantiate redirectResolver", e); 147 } 148 } 149 if (redirectResolver == null) { 150 redirectResolver = new DefaultRedirectResolver(); 151 } 152 } 153 154 @Override 155 public void stop(ComponentContext context) throws InterruptedException { 156 super.stop(context); 157 redirectResolver = null; 158 } 159 160 /** 161 * {@inheritDoc} Multipart download are not yet supported. You can only provide a blob singleton at this time. 162 */ 163 @Override 164 public String storeBlobs(List<Blob> blobs) { 165 if (blobs.size() > 1) { 166 throw new IllegalArgumentException("multipart download not yet implemented"); 167 } 168 TransientStore ts = Framework.getService(TransientStoreService.class).getStore(TRANSIENT_STORE_STORE_NAME); 169 String storeKey = UUID.randomUUID().toString(); 170 ts.putBlobs(storeKey, blobs); 171 ts.setCompleted(storeKey, true); 172 return storeKey; 173 } 174 175 @Override 176 public String getFullDownloadUrl(DocumentModel doc, String xpath, Blob blob, String baseUrl) { 177 ConfigurationService configurationService = Framework.getService(ConfigurationService.class); 178 if (configurationService.isBooleanTrue(DOWNLOAD_URL_FOLLOW_REDIRECT)) { 179 try { 180 URI uri = redirectResolver.getURI(blob, UsageHint.DOWNLOAD, null); 181 if (uri != null) { 182 return uri.toString(); 183 } 184 } catch (IOException e) { 185 log.error(e, e); 186 } 187 } 188 return baseUrl + getDownloadUrl(doc, xpath, blob.getFilename()); 189 } 190 191 @Override 192 public String getDownloadUrl(DocumentModel doc, String xpath, String filename) { 193 return getDownloadUrl(doc.getRepositoryName(), doc.getId(), xpath, filename, doc.getChangeToken()); 194 } 195 196 @Override 197 public String getDownloadUrl(String repositoryName, String docId, String xpath, String filename) { 198 return getDownloadUrl(repositoryName, docId, xpath, filename, null); 199 } 200 201 @Override 202 public String getDownloadUrl(String repositoryName, String docId, String xpath, String filename, 203 String changeToken) { 204 StringBuilder sb = new StringBuilder(); 205 sb.append(NXFILE); 206 sb.append("/").append(repositoryName); 207 sb.append("/").append(docId); 208 if (xpath != null) { 209 sb.append("/").append(xpath); 210 if (filename != null) { 211 // make sure filename doesn't contain path separators 212 filename = getSanitizedFilenameWithoutPath(filename); 213 sb.append("/").append(URIUtils.quoteURIPathComponent(filename, true)); 214 } 215 } 216 if (StringUtils.isNotEmpty(changeToken)) { 217 try { 218 sb.append("?") 219 .append(CoreSession.CHANGE_TOKEN) 220 .append("=") 221 .append(URLEncoder.encode(changeToken, "UTF-8")); 222 } catch (UnsupportedEncodingException e) { 223 log.error("Cannot append changeToken", e); 224 } 225 } 226 return sb.toString(); 227 } 228 229 protected String getSanitizedFilenameWithoutPath(String filename) { 230 int sep = Math.max(filename.lastIndexOf('\\'), filename.lastIndexOf('/')); 231 if (sep != -1) { 232 filename = filename.substring(sep + 1); 233 } 234 235 return FILENAME_SANITIZATION_REGEX.matcher(filename).replaceAll(""); 236 } 237 238 @Override 239 public String getDownloadUrl(String storeKey) { 240 return NXBIGBLOB + "/" + storeKey; 241 } 242 243 /** 244 * Gets the download path and action of the URL to use to download blobs. For instance, from the path 245 * "nxfile/default/3727ef6b-cf8c-4f27-ab2c-79de0171a2c8/files:files/0/file/image.png", the pair 246 * ("default/3727ef6b-cf8c-4f27-ab2c-79de0171a2c8/files:files/0/file/image.png", Action.DOWNLOAD_FROM_DOC) is 247 * returned. 248 * 249 * @param path the path of the URL to use to download blobs 250 * @return the pair download path and action 251 * @since 9.1 252 */ 253 protected Pair<String, Action> getDownloadPathAndAction(String path) { 254 if (path.startsWith("/")) { 255 path = path.substring(1); 256 } 257 int slash = path.indexOf('/'); 258 if (slash < 0) { 259 return null; 260 } 261 262 // remove query string if any 263 path = path.replaceFirst("\\?.*$", ""); 264 265 String type = path.substring(0, slash); 266 String downloadPath = path.substring(slash + 1); 267 switch (type) { 268 case NXDOWNLOADINFO: 269 // used by nxdropout.js 270 return Pair.of(downloadPath, Action.INFO); 271 case NXFILE: 272 case NXBIGFILE: 273 return Pair.of(downloadPath, Action.DOWNLOAD_FROM_DOC); 274 case NXBIGZIPFILE: 275 case NXBIGBLOB: 276 return Pair.of(downloadPath, Action.DOWNLOAD); 277 case NXBLOBSTATUS: 278 return Pair.of(downloadPath, Action.BLOBSTATUS); 279 default: 280 return null; 281 } 282 } 283 284 @Override 285 public Blob resolveBlobFromDownloadUrl(String downloadURL) { 286 Pair<String, Action> pair = getDownloadPathAndAction(downloadURL); 287 if (pair == null) { 288 return null; 289 } 290 String downloadPath = pair.getLeft(); 291 try { 292 DownloadBlobInfo downloadBlobInfo = new DownloadBlobInfo(downloadPath); 293 CoreSession session = CoreInstance.getCoreSession(downloadBlobInfo.repository); 294 DocumentRef docRef = new IdRef(downloadBlobInfo.docId); 295 if (!session.exists(docRef)) { 296 return null; 297 } 298 DocumentModel doc = session.getDocument(docRef); 299 Blob blob = resolveBlob(doc, downloadBlobInfo.xpath); 300 if (!checkPermission(doc, downloadBlobInfo.xpath, blob, null, null)) { 301 return null; 302 } 303 return blob; 304 } catch (IllegalArgumentException e) { 305 return null; 306 } 307 } 308 309 @Override 310 public void handleDownload(HttpServletRequest req, HttpServletResponse resp, String baseUrl, String path) 311 throws IOException { 312 Pair<String, Action> pair = getDownloadPathAndAction(path); 313 if (pair == null) { 314 resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Invalid URL syntax"); 315 return; 316 } 317 String downloadPath = pair.getLeft(); 318 Action action = pair.getRight(); 319 switch (action) { 320 case INFO: 321 handleDownload(req, resp, downloadPath, baseUrl, true); 322 break; 323 case DOWNLOAD_FROM_DOC: 324 handleDownload(req, resp, downloadPath, baseUrl, false); 325 break; 326 case DOWNLOAD: 327 downloadBlob(req, resp, downloadPath, "download"); 328 break; 329 case BLOBSTATUS: 330 downloadBlobStatus(req, resp, downloadPath, "download"); 331 break; 332 default: 333 resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Invalid URL syntax"); 334 } 335 } 336 337 /* Needed because Chemistry wraps HEAD requests to pretend they are GET requests. */ 338 protected static final String CHEMISTRY_HEAD_REQUEST_CLASS = "HEADHttpServletRequestWrapper"; 339 340 protected static boolean isHead(HttpServletRequest request) { 341 return "HEAD".equals(request.getMethod()) || request.getClass().getSimpleName().equals(CHEMISTRY_HEAD_REQUEST_CLASS); 342 } 343 344 protected void handleDownload(HttpServletRequest req, HttpServletResponse resp, String downloadPath, String baseUrl, 345 boolean info) throws IOException { 346 boolean tx = false; 347 DownloadBlobInfo downloadBlobInfo; 348 try { 349 downloadBlobInfo = new DownloadBlobInfo(downloadPath); 350 } catch (IllegalArgumentException e) { 351 resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Invalid URL syntax"); 352 return; 353 } 354 355 try { 356 if (!TransactionHelper.isTransactionActive()) { 357 // Manually start and stop a transaction around repository access to be able to release transactional 358 // resources without waiting for the download that can take a long time (longer than the transaction 359 // timeout) especially if the client or the connection is slow. 360 tx = TransactionHelper.startTransaction(); 361 } 362 String xpath = downloadBlobInfo.xpath; 363 String filename = downloadBlobInfo.filename; 364 CoreSession session = CoreInstance.getCoreSession(downloadBlobInfo.repository); 365 DocumentRef docRef = new IdRef(downloadBlobInfo.docId); 366 if (!session.exists(docRef)) { 367 // Send a security exception to force authentication, if the current user is anonymous 368 NuxeoPrincipal principal = NuxeoPrincipal.getCurrent(); 369 if (principal != null && principal.isAnonymous()) { 370 throw new DocumentSecurityException("Authentication is needed for downloading the blob"); 371 } 372 resp.sendError(HttpServletResponse.SC_NOT_FOUND, "No document found"); 373 return; 374 } 375 DocumentModel doc = session.getDocument(docRef); 376 if (info) { 377 Blob blob = resolveBlob(doc, xpath); 378 if (blob == null) { 379 resp.sendError(HttpServletResponse.SC_NOT_FOUND, "No blob found"); 380 return; 381 } 382 String downloadUrl = baseUrl + getDownloadUrl(doc, xpath, filename); 383 String result = blob.getMimeType() + ':' + URLEncoder.encode(blob.getFilename(), "UTF-8") + ':' 384 + downloadUrl; 385 resp.setContentType("text/plain"); 386 if (!isHead(req)) { 387 resp.getWriter().write(result); 388 resp.getWriter().flush(); 389 } 390 } else { 391 DownloadContext context = DownloadContext.builder(req, resp) 392 .doc(doc) 393 .xpath(xpath) 394 .filename(filename) 395 .reason("download") 396 .build(); 397 downloadBlob(context); 398 } 399 } catch (NuxeoException e) { 400 if (tx) { 401 TransactionHelper.setTransactionRollbackOnly(); 402 } 403 throw new IOException(e); 404 } finally { 405 if (tx) { 406 TransactionHelper.commitOrRollbackTransaction(); 407 } 408 } 409 } 410 411 @Override 412 public void downloadBlobStatus(HttpServletRequest request, HttpServletResponse response, String key, String reason) 413 throws IOException { 414 downloadBlob(request, response, key, reason, true); 415 } 416 417 @Override 418 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, String key, String reason) 419 throws IOException { 420 downloadBlob(request, response, key, reason, false); 421 } 422 423 protected void downloadBlob(HttpServletRequest request, HttpServletResponse response, String key, String reason, 424 boolean status) throws IOException { 425 TransientStore ts = Framework.getService(TransientStoreService.class).getStore(TRANSIENT_STORE_STORE_NAME); 426 if (!ts.exists(key)) { 427 response.sendError(HttpServletResponse.SC_NOT_FOUND); 428 return; 429 } 430 List<Blob> blobs = ts.getBlobs(key); 431 if (blobs == null || blobs.isEmpty()) { 432 response.sendError(HttpServletResponse.SC_NOT_FOUND); 433 return; 434 } 435 if (blobs.size() > 1) { 436 throw new IllegalArgumentException("multipart download not yet implemented"); 437 } 438 if (ts.getParameter(key, TRANSIENT_STORE_PARAM_ERROR) != null) { 439 response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, 440 (String) ts.getParameter(key, TRANSIENT_STORE_PARAM_ERROR)); 441 return; 442 } 443 boolean isCompleted = ts.isCompleted(key); 444 if (!status && !isCompleted) { 445 response.setStatus(HttpServletResponse.SC_ACCEPTED); 446 return; 447 } 448 Blob blob; 449 if (status) { 450 Serializable progress = ts.getParameter(key, TRANSIENT_STORE_PARAM_PROGRESS); 451 blob = new AsyncBlob(key, isCompleted, progress != null ? (int) progress : -1); 452 } else { 453 blob = blobs.get(0); 454 } 455 try { 456 DownloadContext context = DownloadContext.builder(request, response) 457 .blob(blob) 458 .reason(reason) 459 .build(); 460 downloadBlob(context); 461 } finally { 462 if (!status && !isHead(request)) { 463 ts.remove(key); 464 } 465 } 466 } 467 468 @Deprecated 469 @Override 470 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, 471 Blob blob, String filename, String reason) throws IOException { 472 DownloadContext context = DownloadContext.builder(request, response) 473 .doc(doc) 474 .xpath(xpath) 475 .blob(blob) 476 .filename(filename) 477 .reason(reason) 478 .build(); 479 downloadBlob(context); 480 } 481 482 @Deprecated 483 @Override 484 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, 485 Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos) throws IOException { 486 DownloadContext context = DownloadContext.builder(request, response) 487 .doc(doc) 488 .xpath(xpath) 489 .blob(blob) 490 .filename(filename) 491 .reason(reason) 492 .extendedInfos(extendedInfos) 493 .build(); 494 downloadBlob(context); 495 } 496 497 @Deprecated 498 @Override 499 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, 500 Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos, Boolean inline) 501 throws IOException { 502 DownloadContext context = DownloadContext.builder(request, response) 503 .doc(doc) 504 .xpath(xpath) 505 .blob(blob) 506 .filename(filename) 507 .reason(reason) 508 .extendedInfos(extendedInfos) 509 .inline(inline) 510 .build(); 511 downloadBlob(context); 512 } 513 514 @Deprecated 515 @Override 516 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, 517 Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos, Boolean inline, 518 Consumer<ByteRange> blobTransferer) throws IOException { 519 DownloadContext context = DownloadContext.builder(request, response) 520 .doc(doc) 521 .xpath(xpath) 522 .blob(blob) 523 .filename(filename) 524 .reason(reason) 525 .extendedInfos(extendedInfos) 526 .inline(inline) 527 .blobTransferer(blobTransferer) 528 .build(); 529 downloadBlob(context); 530 } 531 532 @Override 533 public void downloadBlob(DownloadContext context) throws IOException { 534 HttpServletRequest request = context.getRequest(); 535 HttpServletResponse response = context.getResponse(); 536 DocumentModel doc = context.getDocumentModel(); 537 String xpath = context.getXPath(); 538 Blob blob = context.getBlob(); 539 if (blob == null) { 540 if (doc == null) { 541 throw new NuxeoException("No doc specified"); 542 } 543 blob = resolveBlob(doc, xpath); 544 if (blob == null) { 545 response.sendError(HttpServletResponse.SC_NOT_FOUND, "No blob found"); 546 return; 547 } 548 } 549 String filename = context.getFilename(); 550 if (filename == null) { 551 filename = blob.getFilename(); 552 } 553 String reason = context.getReason(); 554 String requestReason = (String) request.getAttribute(REQUEST_ATTR_DOWNLOAD_REASON); 555 if (requestReason != null) { 556 reason = requestReason; 557 } 558 Map<String, Serializable> extendedInfos = context.getExtendedInfos(); 559 extendedInfos = extendedInfos == null ? new HashMap<>() : new HashMap<>(extendedInfos); 560 String requestRendition = (String) request.getAttribute(REQUEST_ATTR_DOWNLOAD_RENDITION); 561 if (requestRendition != null) { 562 extendedInfos.put(EXTENDED_INFO_RENDITION, requestRendition); 563 } 564 Boolean inline = context.getInline(); 565 Consumer<ByteRange> blobTransferer = context.getBlobTransferer(); 566 if (blobTransferer == null) { 567 Blob fblob = blob; 568 blobTransferer = byteRange -> transferBlobWithByteRange(fblob, byteRange, response); 569 } 570 Calendar lastModified = context.getLastModified(); 571 if (lastModified == null && doc != null) { 572 try { 573 lastModified = (Calendar) doc.getPropertyValue(DC_MODIFIED); 574 } catch (PropertyNotFoundException | ClassCastException e) { 575 // ignore 576 } 577 } 578 579 // check blob permissions 580 if (!checkPermission(doc, xpath, blob, reason, extendedInfos)) { 581 response.sendError(HttpServletResponse.SC_FORBIDDEN, "Permission denied"); 582 return; 583 } 584 585 // check Blob Manager external download link 586 URI uri = redirectResolver.getURI(blob, UsageHint.DOWNLOAD, request); 587 if (uri != null) { 588 try { 589 extendedInfos.put("redirect", uri.toString()); 590 logDownload(request, doc, xpath, filename, reason, extendedInfos); 591 response.sendRedirect(uri.toString()); 592 } catch (IOException ioe) { 593 DownloadHelper.handleClientDisconnect(ioe); 594 } 595 return; 596 } 597 598 try { 599 String contentType = blob.getMimeType(); 600 // empty is true for an unavailable lazy rendition 601 boolean empty = contentType != null && contentType.contains("empty=true"); 602 603 long length = blob.getLength(); 604 ByteRange byteRange = getByteRange(request, length); 605 606 String digest = blob.getDigest(); 607 String digestAlgorithm = blob.getDigestAlgorithm(); 608 if (digest == null) { 609 digest = DigestUtils.md5Hex(blob.getStream()); 610 digestAlgorithm = MD5; 611 } 612 613 // Want-Digest / Digest 614 Set<String> wantDigests = getWantDigests(request); 615 if (!wantDigests.isEmpty()) { 616 if (wantDigests.contains(digestAlgorithm.toLowerCase())) { 617 // Digest header (RFC3230) 618 response.setHeader("Digest", digestAlgorithm + '=' + hexToBase64(digest)); 619 } 620 if (wantDigests.contains("contentmd5")) { 621 // Content-MD5 header (RFC1864) 622 // deprecated per RFC7231 Appendix B 623 // don't do it if there's a byte range because the spec is inconsistent 624 // see https://trac.ietf.org/trac/httpbis/ticket/178 625 if (byteRange == null && MD5.equalsIgnoreCase(digestAlgorithm)) { 626 response.setHeader("Content-MD5", hexToBase64(digest)); 627 } 628 } 629 } 630 631 addCacheControlHeaders(request, response); 632 633 // If-Modified-Since / Last-Modified 634 if (!empty && lastModified != null) { 635 long lastModifiedMillis = lastModified.getTimeInMillis(); 636 response.setDateHeader("Last-Modified", lastModifiedMillis); 637 long ifModifiedSince; 638 try { 639 ifModifiedSince = request.getDateHeader("If-Modified-Since"); 640 } catch (IllegalArgumentException e) { 641 log.debug("Invalid If-Modified-Since header", e); 642 ifModifiedSince = -1; 643 } 644 if (ifModifiedSince != -1 && ifModifiedSince >= lastModifiedMillis) { 645 // not modified 646 response.sendError(HttpServletResponse.SC_NOT_MODIFIED); 647 return; 648 } 649 } 650 651 // If-None-Match / ETag 652 if (!empty) { 653 String etag = '"' + digest + '"'; // with quotes per RFC7232 2.3 654 response.setHeader("ETag", etag); // re-send even on SC_NOT_MODIFIED 655 String ifNoneMatch = request.getHeader("If-None-Match"); 656 if (ifNoneMatch != null) { 657 boolean match = false; 658 if (ifNoneMatch.equals("*")) { 659 match = true; 660 } else { 661 for (String previousEtag : StringUtils.split(ifNoneMatch, ", ")) { 662 if (previousEtag.equals(etag)) { 663 match = true; 664 break; 665 } 666 } 667 } 668 if (match) { 669 String method = request.getMethod(); 670 if (method.equals("GET") || method.equals("HEAD")) { 671 response.sendError(HttpServletResponse.SC_NOT_MODIFIED); 672 } else { 673 // per RFC7232 3.2 674 response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); 675 } 676 return; 677 } 678 } 679 } 680 681 // regular processing 682 683 if (StringUtils.isBlank(filename)) { 684 filename = StringUtils.defaultIfBlank(blob.getFilename(), "file"); 685 } 686 String contentDisposition = DownloadHelper.getRFC2231ContentDisposition(request, filename, inline); 687 response.setHeader("Content-Disposition", contentDisposition); 688 response.setContentType(contentType); 689 if (StringUtils.isNotBlank(blob.getEncoding())) { 690 try { 691 response.setCharacterEncoding(blob.getEncoding()); 692 } catch (IllegalArgumentException e) { 693 // ignore invalid encoding 694 } 695 } 696 697 response.setHeader("Accept-Ranges", "bytes"); 698 if (byteRange != null) { 699 response.setHeader("Content-Range", 700 "bytes " + byteRange.getStart() + "-" + byteRange.getEnd() + "/" + length); 701 response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); 702 } 703 long contentLength = byteRange == null ? length : byteRange.getLength(); 704 response.setContentLengthLong(contentLength); 705 706 // log the download but not if it's a random byte range 707 if (byteRange == null || byteRange.getStart() == 0) { 708 logDownload(request, doc, xpath, filename, reason, extendedInfos); 709 } 710 711 String xAccelLocation = request.getHeader(NginxConstants.X_ACCEL_LOCATION_HEADER); 712 if (Framework.isBooleanPropertyTrue(NginxConstants.X_ACCEL_ENABLED) 713 && StringUtils.isNotEmpty(xAccelLocation)) { 714 File file = blob.getFile(); 715 if (file != null && blob instanceof ManagedBlob) { 716 File storageDir; 717 BlobManager blobManager = Framework.getService(BlobManager.class); 718 BlobProvider blobProvider = blobManager.getBlobProvider(blob); 719 if (blobProvider instanceof LocalBlobProvider) { 720 storageDir = ((LocalBlobProvider) blobProvider).getStorageDir().toFile(); 721 } else if (blobProvider.getBinaryManager() instanceof DefaultBinaryManager) { 722 storageDir = ((DefaultBinaryManager) blobProvider.getBinaryManager()).getStorageDir(); 723 } else { 724 throw new NuxeoException("Cannot use Nginx accelerated download with blob provider: " 725 + blobProvider.getClass().getName()); 726 } 727 String relative = storageDir.toURI().relativize(file.toURI()).getPath(); 728 if (xAccelLocation.endsWith("/")) { 729 xAccelLocation = xAccelLocation + relative; 730 } else { 731 xAccelLocation = xAccelLocation + "/" + relative; 732 } 733 response.setHeader(NginxConstants.X_ACCEL_REDIRECT_HEADER, xAccelLocation); 734 return; 735 } 736 } 737 738 if (!isHead(request)) { 739 // execute the final download 740 blobTransferer.accept(byteRange); 741 } 742 } catch (UncheckedIOException e) { 743 DownloadHelper.handleClientDisconnect(e.getCause()); 744 } catch (IOException ioe) { 745 DownloadHelper.handleClientDisconnect(ioe); 746 } 747 } 748 749 protected ByteRange getByteRange(HttpServletRequest request, long length) { 750 String range = request.getHeader("Range"); 751 if (StringUtils.isBlank(range)) { 752 return null; 753 } 754 ByteRange byteRange = DownloadHelper.parseRange(range, length); 755 if (byteRange == null) { 756 log.debug("Invalid byte range received: {}", range); 757 } 758 return byteRange; 759 } 760 761 protected Set<String> getWantDigests(HttpServletRequest request) { 762 Enumeration<String> values = request.getHeaders("Want-Digest"); 763 if (values == null) { 764 return Collections.emptySet(); 765 } 766 Set<String> wantDigests = new HashSet<>(); 767 for (String value : Collections.list(values)) { 768 int semicolon = value.indexOf(';'); 769 if (semicolon >= 0) { 770 value = value.substring(0, semicolon); 771 } 772 wantDigests.add(value.trim().toLowerCase()); 773 } 774 return wantDigests; 775 } 776 777 protected static String hexToBase64(String hexString) { 778 try { 779 return Base64.encodeBase64String(Hex.decodeHex(hexString.toCharArray())); 780 } catch (DecoderException e) { 781 throw new NuxeoException(e); 782 } 783 } 784 785 protected void transferBlobWithByteRange(Blob blob, ByteRange byteRange, HttpServletResponse response) { 786 transferBlobWithByteRange(blob, byteRange, () -> { 787 try { 788 return response.getOutputStream(); 789 } catch (IOException e) { 790 throw new UncheckedIOException(e); 791 } 792 }); 793 try { 794 response.flushBuffer(); 795 } catch (IOException e) { 796 throw new UncheckedIOException(e); 797 } 798 } 799 800 @Override 801 public void transferBlobWithByteRange(Blob blob, ByteRange byteRange, Supplier<OutputStream> outputStreamSupplier) { 802 try (InputStream in = blob.getStream()) { 803 @SuppressWarnings("resource") 804 OutputStream out = outputStreamSupplier.get(); // not ours to close 805 BufferingServletOutputStream.stopBuffering(out); 806 if (byteRange == null) { 807 IOUtils.copy(in, out); 808 } else { 809 @SuppressWarnings("resource") // closing the original stream is enough 810 InputStream substream = byteRange.forStream(in); 811 // don't use IOUtils.copyLarge because it uses a skip method that reads 812 // all intervening bytes, which is inefficient for skippable streams 813 IOUtils.copy(substream, out); 814 } 815 out.flush(); 816 } catch (IOException e) { 817 throw new UncheckedIOException(e); 818 } 819 } 820 821 protected String fixXPath(String xpath) { 822 // Hack for Flash Url wich doesn't support ':' char 823 return xpath == null ? null : xpath.replace(';', ':'); 824 } 825 826 @Override 827 public Blob resolveBlob(DocumentModel doc) { 828 BlobHolderAdapterService blobHolderAdapterService = Framework.getService(BlobHolderAdapterService.class); 829 return blobHolderAdapterService.getBlobHolderAdapter(doc, "download").getBlob(); 830 } 831 832 @Override 833 public Blob resolveBlob(DocumentModel doc, String xpath) { 834 if (xpath == null) { 835 return resolveBlob(doc); 836 } 837 xpath = fixXPath(xpath); 838 Blob blob; 839 if (xpath.startsWith(BLOBHOLDER_PREFIX)) { 840 BlobHolder bh = doc.getAdapter(BlobHolder.class); 841 if (bh == null) { 842 log.debug("{} is not a BlobHolder", doc); 843 return null; 844 } 845 String suffix = xpath.substring(BLOBHOLDER_PREFIX.length()); 846 int index; 847 try { 848 index = Integer.parseInt(suffix); 849 } catch (NumberFormatException e) { 850 log.debug(e.getMessage()); 851 return null; 852 } 853 if (!suffix.equals(Integer.toString(index))) { 854 // attempt to use a non-canonical integer, could be used to bypass 855 // a permission function checking just "blobholder:1" and receiving "blobholder:01" 856 log.debug("Non-canonical index: {}", suffix); 857 return null; 858 } 859 if (index == 0) { 860 blob = bh.getBlob(); 861 } else { 862 blob = bh.getBlobs().get(index); 863 } 864 } else { 865 if (!xpath.contains(":")) { 866 // attempt to use a xpath not prefix-qualified, could be used to bypass 867 // a permission function checking just "file:content" and receiving "content" 868 log.debug("Non-canonical xpath: {}", xpath); 869 return null; 870 } 871 try { 872 blob = (Blob) doc.getPropertyValue(xpath); 873 } catch (PropertyNotFoundException e) { 874 log.debug("Property '{}' not found", xpath, e); 875 return null; 876 } 877 } 878 return blob; 879 } 880 881 @Override 882 public boolean checkPermission(DocumentModel doc, String xpath, Blob blob, String reason, 883 Map<String, Serializable> extendedInfos) { 884 List<DownloadPermissionDescriptor> descriptors = getDescriptors(XP_PERMISSIONS); 885 if (descriptors.isEmpty()) { 886 return true; 887 } 888 xpath = fixXPath(xpath); 889 Map<String, Object> context = new HashMap<>(); 890 Map<String, Serializable> ei = extendedInfos == null ? Collections.emptyMap() : extendedInfos; 891 NuxeoPrincipal currentUser = NuxeoPrincipal.getCurrent(); 892 context.put("Document", doc); 893 context.put("XPath", xpath); 894 context.put("Blob", blob); 895 context.put("Reason", reason); 896 context.put("Infos", ei); 897 context.put("Rendition", ei.get(EXTENDED_INFO_RENDITION)); 898 context.put("CurrentUser", currentUser); 899 for (DownloadPermissionDescriptor descriptor : descriptors) { 900 ScriptEngine engine = scriptEngineManager.getEngineByName(descriptor.getScriptLanguage()); 901 if (engine == null) { 902 throw new NuxeoException("Engine not found for language: " + descriptor.getScriptLanguage() 903 + " in permission: " + descriptor.name); 904 } 905 if (!(engine instanceof Invocable)) { 906 throw new NuxeoException("Engine " + engine.getClass().getName() + " not Invocable for language: " 907 + descriptor.getScriptLanguage() + " in permission: " + descriptor.name); 908 } 909 Object result; 910 try { 911 engine.eval(descriptor.script); 912 engine.getBindings(ScriptContext.ENGINE_SCOPE).putAll(context); 913 result = ((Invocable) engine).invokeFunction(RUN_FUNCTION); 914 } catch (NoSuchMethodException e) { 915 throw new NuxeoException("Script does not contain function: " + RUN_FUNCTION + "() in permission: " 916 + descriptor.name, e); 917 } catch (ScriptException e) { 918 log.error("Failed to evaluate script: {}", descriptor.name, e); 919 continue; 920 } 921 if (!(result instanceof Boolean)) { 922 log.error("Failed to get boolean result from permission: {} ({})", descriptor.name, result); 923 continue; 924 } 925 boolean allow = ((Boolean) result).booleanValue(); 926 if (!allow) { 927 return false; 928 } 929 } 930 return true; 931 } 932 933 /** 934 * Internet Explorer file downloads over SSL do not work with certain HTTP cache control headers 935 * <p> 936 * See http://support.microsoft.com/kb/323308/ 937 * <p> 938 * What is not mentioned in the above Knowledge Base is that "Pragma: no-cache" also breaks download in MSIE over 939 * SSL 940 */ 941 protected void addCacheControlHeaders(HttpServletRequest request, HttpServletResponse response) { 942 String userAgent = request.getHeader("User-Agent"); 943 boolean secure = request.isSecure(); 944 if (!secure) { 945 String nvh = request.getHeader(NUXEO_VIRTUAL_HOST); 946 if (nvh == null) { 947 nvh = Framework.getProperty(VH_PARAM); 948 } 949 if (nvh != null) { 950 secure = nvh.startsWith("https"); 951 } 952 } 953 if (userAgent != null && userAgent.contains("MSIE") && (secure || forceNoCacheOnMSIE())) { 954 String cacheControl = "max-age=15, must-revalidate"; 955 log.debug("Setting Cache-Control: {}", cacheControl); 956 response.setHeader("Cache-Control", cacheControl); 957 } 958 } 959 960 protected static boolean forceNoCacheOnMSIE() { 961 // see NXP-7759 962 return Framework.isBooleanPropertyTrue(FORCE_NO_CACHE_ON_MSIE); 963 } 964 965 @Override 966 public void logDownload(HttpServletRequest request, DocumentModel doc, String xpath, String filename, String reason, 967 Map<String, Serializable> extendedInfos) { 968 if (request != null && isHead(request)) { 969 // don't log HEAD requests 970 return; 971 } 972 if ("webengine".equals(reason)) { 973 // don't log JSON operation results as downloads 974 return; 975 } 976 EventService eventService = Framework.getService(EventService.class); 977 if (eventService == null) { 978 return; 979 } 980 EventContext ctx; 981 if (doc != null) { 982 CoreSession session = doc.getCoreSession(); 983 NuxeoPrincipal principal = session == null ? getPrincipal() : session.getPrincipal(); 984 ctx = new DocumentEventContext(session, principal, doc); 985 ctx.setProperty(CoreEventConstants.REPOSITORY_NAME, doc.getRepositoryName()); 986 } else { 987 ctx = new EventContextImpl(null, getPrincipal()); 988 } 989 Map<String, Serializable> map = new HashMap<>(); 990 map.put("blobXPath", xpath); 991 map.put("blobFilename", filename); 992 map.put("downloadReason", reason); 993 if (extendedInfos != null) { 994 map.putAll(extendedInfos); 995 } 996 ctx.setProperty("extendedInfos", (Serializable) map); 997 ctx.setProperty("comment", filename); 998 Event event = ctx.newEvent(EVENT_NAME); 999 eventService.fireEvent(event); 1000 } 1001 1002 protected static NuxeoPrincipal getPrincipal() { 1003 return NuxeoPrincipal.getCurrent(); 1004 } 1005 1006}