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.IOException; 023import java.io.InputStream; 024import java.io.OutputStream; 025import java.io.Serializable; 026import java.io.UncheckedIOException; 027import java.net.URI; 028import java.net.URLEncoder; 029import java.security.Principal; 030import java.util.ArrayList; 031import java.util.Collections; 032import java.util.HashMap; 033import java.util.List; 034import java.util.Map; 035import java.util.Objects; 036import java.util.UUID; 037import java.util.function.Consumer; 038import java.util.function.Supplier; 039import java.util.regex.Pattern; 040 041import javax.script.Invocable; 042import javax.script.ScriptContext; 043import javax.script.ScriptEngine; 044import javax.script.ScriptEngineManager; 045import javax.script.ScriptException; 046import javax.servlet.http.HttpServletRequest; 047import javax.servlet.http.HttpServletResponse; 048 049import org.apache.commons.codec.digest.DigestUtils; 050import org.apache.commons.io.IOUtils; 051import org.apache.commons.lang3.StringUtils; 052import org.apache.commons.lang3.tuple.Pair; 053import org.apache.commons.logging.Log; 054import org.apache.commons.logging.LogFactory; 055import org.nuxeo.common.utils.URIUtils; 056import org.nuxeo.ecm.core.api.Blob; 057import org.nuxeo.ecm.core.api.CloseableCoreSession; 058import org.nuxeo.ecm.core.api.CoreInstance; 059import org.nuxeo.ecm.core.api.CoreSession; 060import org.nuxeo.ecm.core.api.DocumentModel; 061import org.nuxeo.ecm.core.api.DocumentRef; 062import org.nuxeo.ecm.core.api.DocumentSecurityException; 063import org.nuxeo.ecm.core.api.IdRef; 064import org.nuxeo.ecm.core.api.NuxeoException; 065import org.nuxeo.ecm.core.api.NuxeoPrincipal; 066import org.nuxeo.ecm.core.api.blobholder.BlobHolder; 067import org.nuxeo.ecm.core.api.blobholder.BlobHolderAdapterService; 068import org.nuxeo.ecm.core.api.event.CoreEventConstants; 069import org.nuxeo.ecm.core.api.impl.blob.AsyncBlob; 070import org.nuxeo.ecm.core.api.local.ClientLoginModule; 071import org.nuxeo.ecm.core.api.model.PropertyNotFoundException; 072import org.nuxeo.ecm.core.blob.BlobManager.UsageHint; 073import org.nuxeo.ecm.core.event.Event; 074import org.nuxeo.ecm.core.event.EventContext; 075import org.nuxeo.ecm.core.event.EventService; 076import org.nuxeo.ecm.core.event.impl.DocumentEventContext; 077import org.nuxeo.ecm.core.event.impl.EventContextImpl; 078import org.nuxeo.ecm.core.transientstore.api.TransientStore; 079import org.nuxeo.ecm.core.transientstore.api.TransientStoreService; 080import org.nuxeo.runtime.api.Framework; 081import org.nuxeo.runtime.model.ComponentInstance; 082import org.nuxeo.runtime.model.DefaultComponent; 083import org.nuxeo.runtime.model.SimpleContributionRegistry; 084import org.nuxeo.runtime.transaction.TransactionHelper; 085 086/** 087 * This service allows the download of blobs to a HTTP response. 088 * 089 * @since 7.3 090 */ 091public class DownloadServiceImpl extends DefaultComponent implements DownloadService { 092 093 private static final Log log = LogFactory.getLog(DownloadServiceImpl.class); 094 095 protected static final int DOWNLOAD_BUFFER_SIZE = 1024 * 512; 096 097 private static final String NUXEO_VIRTUAL_HOST = "nuxeo-virtual-host"; 098 099 private static final String VH_PARAM = "nuxeo.virtual.host"; 100 101 private static final String FORCE_NO_CACHE_ON_MSIE = "org.nuxeo.download.force.nocache.msie"; 102 103 private static final String XP = "permissions"; 104 105 private static final String REDIRECT_RESOLVER = "redirectResolver"; 106 107 private static final String RUN_FUNCTION = "run"; 108 109 private static final Pattern FILENAME_SANITIZATION_REGEX = Pattern.compile(";\\w+=.*"); 110 111 protected enum Action { 112 DOWNLOAD, DOWNLOAD_FROM_DOC, INFO, BLOBSTATUS 113 }; 114 115 private DownloadPermissionRegistry registry = new DownloadPermissionRegistry(); 116 117 private ScriptEngineManager scriptEngineManager; 118 119 protected RedirectResolver redirectResolver = new DefaultRedirectResolver(); 120 121 protected List<RedirectResolverDescriptor> redirectResolverContributions = new ArrayList<>(); 122 123 public static class DownloadPermissionRegistry extends SimpleContributionRegistry<DownloadPermissionDescriptor> { 124 125 @Override 126 public String getContributionId(DownloadPermissionDescriptor contrib) { 127 return contrib.getName(); 128 } 129 130 @Override 131 public boolean isSupportingMerge() { 132 return true; 133 } 134 135 @Override 136 public DownloadPermissionDescriptor clone(DownloadPermissionDescriptor orig) { 137 return new DownloadPermissionDescriptor(orig); 138 } 139 140 @Override 141 public void merge(DownloadPermissionDescriptor src, DownloadPermissionDescriptor dst) { 142 dst.merge(src); 143 } 144 145 public DownloadPermissionDescriptor getDownloadPermissionDescriptor(String id) { 146 return getCurrentContribution(id); 147 } 148 149 /** Returns descriptors sorted by name. */ 150 public List<DownloadPermissionDescriptor> getDownloadPermissionDescriptors() { 151 List<DownloadPermissionDescriptor> descriptors = new ArrayList<>(currentContribs.values()); 152 Collections.sort(descriptors); 153 return descriptors; 154 } 155 } 156 157 public DownloadServiceImpl() { 158 scriptEngineManager = new ScriptEngineManager(); 159 } 160 161 @Override 162 public void registerContribution(Object contribution, String extensionPoint, ComponentInstance contributor) { 163 if (XP.equals(extensionPoint)) { 164 DownloadPermissionDescriptor descriptor = (DownloadPermissionDescriptor) contribution; 165 registry.addContribution(descriptor); 166 } else if (REDIRECT_RESOLVER.equals(extensionPoint)) { 167 redirectResolver = ((RedirectResolverDescriptor) contribution).getObject(); 168 // Save contribution 169 redirectResolverContributions.add((RedirectResolverDescriptor) contribution); 170 } else { 171 throw new UnsupportedOperationException(extensionPoint); 172 } 173 } 174 175 @Override 176 public void unregisterContribution(Object contribution, String extensionPoint, ComponentInstance contributor) { 177 if (XP.equals(extensionPoint)) { 178 DownloadPermissionDescriptor descriptor = (DownloadPermissionDescriptor) contribution; 179 registry.removeContribution(descriptor); 180 } else if (REDIRECT_RESOLVER.equals(extensionPoint)) { 181 redirectResolverContributions.remove(contribution); 182 if (redirectResolverContributions.size() == 0) { 183 // If no more custom contribution go back to the default one 184 redirectResolver = new DefaultRedirectResolver(); 185 } else { 186 // Go back to the last contribution added 187 redirectResolver = redirectResolverContributions.get(redirectResolverContributions.size() - 1) 188 .getObject(); 189 } 190 } else { 191 throw new UnsupportedOperationException(extensionPoint); 192 } 193 } 194 195 /** 196 * {@inheritDoc} Multipart download are not yet supported. You can only provide a blob singleton at this time. 197 */ 198 @Override 199 public String storeBlobs(List<Blob> blobs) { 200 if (blobs.size() > 1) { 201 throw new IllegalArgumentException("multipart download not yet implemented"); 202 } 203 TransientStore ts = Framework.getService(TransientStoreService.class).getStore(TRANSIENT_STORE_STORE_NAME); 204 String storeKey = UUID.randomUUID().toString(); 205 ts.putBlobs(storeKey, blobs); 206 ts.setCompleted(storeKey, true); 207 return storeKey; 208 } 209 210 @Override 211 public String getDownloadUrl(DocumentModel doc, String xpath, String filename) { 212 return getDownloadUrl(doc.getRepositoryName(), doc.getId(), xpath, filename); 213 } 214 215 @Override 216 public String getDownloadUrl(String repositoryName, String docId, String xpath, String filename) { 217 StringBuilder sb = new StringBuilder(); 218 sb.append(NXFILE); 219 sb.append("/").append(repositoryName); 220 sb.append("/").append(docId); 221 if (xpath != null) { 222 sb.append("/").append(xpath); 223 if (filename != null) { 224 // make sure filename doesn't contain path separators 225 filename = getSanitizedFilenameWithoutPath(filename); 226 sb.append("/").append(URIUtils.quoteURIPathComponent(filename, true)); 227 } 228 } 229 return sb.toString(); 230 } 231 232 protected String getSanitizedFilenameWithoutPath(String filename) { 233 int sep = Math.max(filename.lastIndexOf('\\'), filename.lastIndexOf('/')); 234 if (sep != -1) { 235 filename = filename.substring(sep + 1); 236 } 237 238 return FILENAME_SANITIZATION_REGEX.matcher(filename).replaceAll(""); 239 } 240 241 @Override 242 public String getDownloadUrl(String storeKey) { 243 return NXBIGBLOB + "/" + storeKey; 244 } 245 246 /** 247 * Gets the download path and action of the URL to use to download blobs. For instance, from the path 248 * "nxfile/default/3727ef6b-cf8c-4f27-ab2c-79de0171a2c8/files:files/0/file/image.png", the pair 249 * ("default/3727ef6b-cf8c-4f27-ab2c-79de0171a2c8/files:files/0/file/image.png", Action.DOWNLOAD_FROM_DOC) is 250 * returned. 251 * 252 * @param path the path of the URL to use to download blobs 253 * @return the pair download path and action 254 * @since 9.1 255 */ 256 protected Pair<String, Action> getDownloadPathAndAction(String path) { 257 if (path.startsWith("/")) { 258 path = path.substring(1); 259 } 260 int slash = path.indexOf('/'); 261 if (slash < 0) { 262 return null; 263 } 264 String type = path.substring(0, slash); 265 String downloadPath = path.substring(slash + 1); 266 switch (type) { 267 case NXDOWNLOADINFO: 268 // used by nxdropout.js 269 return Pair.of(downloadPath, Action.INFO); 270 case NXFILE: 271 case NXBIGFILE: 272 return Pair.of(downloadPath, Action.DOWNLOAD_FROM_DOC); 273 case NXBIGZIPFILE: 274 case NXBIGBLOB: 275 return Pair.of(downloadPath, Action.DOWNLOAD); 276 case NXBLOBSTATUS: 277 return Pair.of(downloadPath, Action.BLOBSTATUS); 278 default: 279 return null; 280 } 281 } 282 283 @Override 284 public Blob resolveBlobFromDownloadUrl(String url) { 285 String nuxeoUrl = Framework.getProperty("nuxeo.url"); 286 if (!url.startsWith(nuxeoUrl)) { 287 return null; 288 } 289 String path = url.substring(nuxeoUrl.length() + 1); 290 Pair<String, Action> pair = getDownloadPathAndAction(path); 291 if (pair == null) { 292 return null; 293 } 294 String downloadPath = pair.getLeft(); 295 try { 296 DownloadBlobInfo downloadBlobInfo = new DownloadBlobInfo(downloadPath); 297 try (CloseableCoreSession session = CoreInstance.openCoreSession(downloadBlobInfo.repository)) { 298 DocumentRef docRef = new IdRef(downloadBlobInfo.docId); 299 if (!session.exists(docRef)) { 300 return null; 301 } 302 DocumentModel doc = session.getDocument(docRef); 303 return resolveBlob(doc, downloadBlobInfo.xpath); 304 } 305 } catch (IllegalArgumentException e) { 306 return null; 307 } 308 } 309 310 @Override 311 public void handleDownload(HttpServletRequest req, HttpServletResponse resp, String baseUrl, String path) 312 throws IOException { 313 Pair<String, Action> pair = getDownloadPathAndAction(path); 314 if (pair == null) { 315 resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Invalid URL syntax"); 316 return; 317 } 318 String downloadPath = pair.getLeft(); 319 Action action = pair.getRight(); 320 switch (action) { 321 case INFO: 322 handleDownload(req, resp, downloadPath, baseUrl, true); 323 break; 324 case DOWNLOAD_FROM_DOC: 325 handleDownload(req, resp, downloadPath, baseUrl, false); 326 break; 327 case DOWNLOAD: 328 downloadBlob(req, resp, downloadPath, "download"); 329 break; 330 case BLOBSTATUS: 331 downloadBlobStatus(req, resp, downloadPath, "download"); 332 break; 333 default: 334 resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Invalid URL syntax"); 335 } 336 } 337 338 protected void handleDownload(HttpServletRequest req, HttpServletResponse resp, String downloadPath, String baseUrl, 339 boolean info) throws IOException { 340 boolean tx = false; 341 DownloadBlobInfo downloadBlobInfo; 342 try { 343 downloadBlobInfo = new DownloadBlobInfo(downloadPath); 344 } catch (IllegalArgumentException e) { 345 resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Invalid URL syntax"); 346 return; 347 } 348 349 try { 350 if (!TransactionHelper.isTransactionActive()) { 351 // Manually start and stop a transaction around repository access to be able to release transactional 352 // resources without waiting for the download that can take a long time (longer than the transaction 353 // timeout) especially if the client or the connection is slow. 354 tx = TransactionHelper.startTransaction(); 355 } 356 String xpath = downloadBlobInfo.xpath; 357 String filename = downloadBlobInfo.filename; 358 try (CloseableCoreSession session = CoreInstance.openCoreSession(downloadBlobInfo.repository)) { 359 DocumentRef docRef = new IdRef(downloadBlobInfo.docId); 360 if (!session.exists(docRef)) { 361 // Send a security exception to force authentication, if the current user is anonymous 362 Principal principal = req.getUserPrincipal(); 363 if (principal instanceof NuxeoPrincipal) { 364 NuxeoPrincipal nuxeoPrincipal = (NuxeoPrincipal) principal; 365 if (nuxeoPrincipal.isAnonymous()) { 366 throw new DocumentSecurityException("Authentication is needed for downloading the blob"); 367 } 368 } 369 resp.sendError(HttpServletResponse.SC_NOT_FOUND, "No document found"); 370 return; 371 } 372 DocumentModel doc = session.getDocument(docRef); 373 if (info) { 374 Blob blob = resolveBlob(doc, xpath); 375 if (blob == null) { 376 resp.sendError(HttpServletResponse.SC_NOT_FOUND, "No blob found"); 377 return; 378 } 379 String downloadUrl = baseUrl + getDownloadUrl(doc, xpath, filename); 380 String result = blob.getMimeType() + ':' + URLEncoder.encode(blob.getFilename(), "UTF-8") + ':' 381 + downloadUrl; 382 resp.setContentType("text/plain"); 383 resp.getWriter().write(result); 384 resp.getWriter().flush(); 385 } else { 386 downloadBlob(req, resp, doc, xpath, null, filename, "download"); 387 } 388 } 389 } catch (NuxeoException e) { 390 if (tx) { 391 TransactionHelper.setTransactionRollbackOnly(); 392 } 393 throw new IOException(e); 394 } finally { 395 if (tx) { 396 TransactionHelper.commitOrRollbackTransaction(); 397 } 398 } 399 } 400 401 @Override 402 public void downloadBlobStatus(HttpServletRequest request, HttpServletResponse response, String key, String reason) 403 throws IOException { 404 this.downloadBlob(request, response, key, reason, true); 405 } 406 407 @Override 408 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, String key, String reason) 409 throws IOException { 410 this.downloadBlob(request, response, key, reason, false); 411 } 412 413 protected void downloadBlob(HttpServletRequest request, HttpServletResponse response, String key, String reason, 414 boolean status) throws IOException { 415 TransientStore ts = Framework.getService(TransientStoreService.class).getStore(TRANSIENT_STORE_STORE_NAME); 416 if (!ts.exists(key)) { 417 response.sendError(HttpServletResponse.SC_NOT_FOUND); 418 return; 419 } 420 List<Blob> blobs = ts.getBlobs(key); 421 if (blobs == null || blobs.isEmpty()) { 422 response.sendError(HttpServletResponse.SC_NOT_FOUND); 423 return; 424 } 425 if (blobs.size() > 1) { 426 throw new IllegalArgumentException("multipart download not yet implemented"); 427 } 428 if (ts.getParameter(key, TRANSIENT_STORE_PARAM_ERROR) != null) { 429 response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, 430 (String) ts.getParameter(key, TRANSIENT_STORE_PARAM_ERROR)); 431 } else { 432 boolean isCompleted = ts.isCompleted(key); 433 if (!status && !isCompleted) { 434 response.setStatus(HttpServletResponse.SC_ACCEPTED); 435 return; 436 } 437 Blob blob; 438 if (status) { 439 Serializable progress = ts.getParameter(key, TRANSIENT_STORE_PARAM_PROGRESS); 440 blob = new AsyncBlob(key, isCompleted, progress != null ? (int) progress : -1); 441 } else { 442 blob = blobs.get(0); 443 } 444 try { 445 downloadBlob(request, response, null, null, blob, blob.getFilename(), reason); 446 } finally { 447 if (!status) { 448 ts.remove(key); 449 } 450 } 451 } 452 } 453 454 @Override 455 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, 456 Blob blob, String filename, String reason) throws IOException { 457 downloadBlob(request, response, doc, xpath, blob, filename, reason, Collections.emptyMap()); 458 } 459 460 @Override 461 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, 462 Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos) throws IOException { 463 downloadBlob(request, response, doc, xpath, blob, filename, reason, extendedInfos, null); 464 } 465 466 @Override 467 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, 468 Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos, Boolean inline) 469 throws IOException { 470 if (blob == null) { 471 if (doc == null) { 472 throw new NuxeoException("No doc specified"); 473 } 474 blob = resolveBlob(doc, xpath); 475 if (blob == null) { 476 response.sendError(HttpServletResponse.SC_NOT_FOUND, "No blob found"); 477 return; 478 } 479 } 480 final Blob fblob = blob; 481 downloadBlob(request, response, doc, xpath, blob, filename, reason, extendedInfos, inline, 482 byteRange -> transferBlobWithByteRange(fblob, byteRange, response)); 483 } 484 485 @Override 486 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, 487 Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos, Boolean inline, 488 Consumer<ByteRange> blobTransferer) throws IOException { 489 Objects.requireNonNull(blob); 490 // check blob permissions 491 if (!checkPermission(doc, xpath, blob, reason, extendedInfos)) { 492 response.sendError(HttpServletResponse.SC_FORBIDDEN, "Permission denied"); 493 return; 494 } 495 496 // check Blob Manager external download link 497 URI uri = redirectResolver.getURI(blob, UsageHint.DOWNLOAD, request); 498 if (uri != null) { 499 try { 500 Map<String, Serializable> ei = new HashMap<>(); 501 if (extendedInfos != null) { 502 ei.putAll(extendedInfos); 503 } 504 ei.put("redirect", uri.toString()); 505 logDownload(doc, xpath, filename, reason, ei); 506 response.sendRedirect(uri.toString()); 507 } catch (IOException ioe) { 508 DownloadHelper.handleClientDisconnect(ioe); 509 } 510 return; 511 } 512 513 try { 514 String digest = blob.getDigest(); 515 if (digest == null) { 516 digest = DigestUtils.md5Hex(blob.getStream()); 517 } 518 String etag = '"' + digest + '"'; // with quotes per RFC7232 2.3 519 response.setHeader("ETag", etag); // re-send even on SC_NOT_MODIFIED 520 addCacheControlHeaders(request, response); 521 522 String ifNoneMatch = request.getHeader("If-None-Match"); 523 if (ifNoneMatch != null) { 524 boolean match = false; 525 if (ifNoneMatch.equals("*")) { 526 match = true; 527 } else { 528 for (String previousEtag : StringUtils.split(ifNoneMatch, ", ")) { 529 if (previousEtag.equals(etag)) { 530 match = true; 531 break; 532 } 533 } 534 } 535 if (match) { 536 String method = request.getMethod(); 537 if (method.equals("GET") || method.equals("HEAD")) { 538 response.sendError(HttpServletResponse.SC_NOT_MODIFIED); 539 } else { 540 // per RFC7232 3.2 541 response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); 542 } 543 return; 544 } 545 } 546 547 // regular processing 548 549 if (StringUtils.isBlank(filename)) { 550 filename = StringUtils.defaultIfBlank(blob.getFilename(), "file"); 551 } 552 String contentDisposition = DownloadHelper.getRFC2231ContentDisposition(request, filename, inline); 553 response.setHeader("Content-Disposition", contentDisposition); 554 response.setContentType(blob.getMimeType()); 555 if (StringUtils.isNotBlank(blob.getEncoding())) { 556 try { 557 response.setCharacterEncoding(blob.getEncoding()); 558 } catch (IllegalArgumentException e) { 559 // ignore invalid encoding 560 } 561 } 562 563 long length = blob.getLength(); 564 response.setHeader("Accept-Ranges", "bytes"); 565 String range = request.getHeader("Range"); 566 ByteRange byteRange; 567 if (StringUtils.isBlank(range)) { 568 byteRange = null; 569 } else { 570 byteRange = DownloadHelper.parseRange(range, length); 571 if (byteRange == null) { 572 log.error("Invalid byte range received: " + range); 573 } else { 574 response.setHeader("Content-Range", 575 "bytes " + byteRange.getStart() + "-" + byteRange.getEnd() + "/" + length); 576 response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); 577 } 578 } 579 long contentLength = byteRange == null ? length : byteRange.getLength(); 580 if (contentLength < Integer.MAX_VALUE) { 581 response.setContentLength((int) contentLength); 582 } 583 584 logDownload(doc, xpath, filename, reason, extendedInfos); 585 586 // execute the final download 587 blobTransferer.accept(byteRange); 588 } catch (UncheckedIOException e) { 589 DownloadHelper.handleClientDisconnect(e.getCause()); 590 } catch (IOException ioe) { 591 DownloadHelper.handleClientDisconnect(ioe); 592 } 593 } 594 595 protected void transferBlobWithByteRange(Blob blob, ByteRange byteRange, HttpServletResponse response) 596 throws UncheckedIOException { 597 transferBlobWithByteRange(blob, byteRange, () -> { 598 try { 599 return response.getOutputStream(); 600 } catch (IOException e) { 601 throw new UncheckedIOException(e); 602 } 603 }); 604 try { 605 response.flushBuffer(); 606 } catch (IOException e) { 607 throw new UncheckedIOException(e); 608 } 609 } 610 611 @Override 612 public void transferBlobWithByteRange(Blob blob, ByteRange byteRange, Supplier<OutputStream> outputStreamSupplier) 613 throws UncheckedIOException { 614 try (InputStream in = blob.getStream()) { 615 @SuppressWarnings("resource") 616 OutputStream out = outputStreamSupplier.get(); // not ours to close 617 BufferingServletOutputStream.stopBuffering(out); 618 if (byteRange == null) { 619 IOUtils.copy(in, out); 620 } else { 621 IOUtils.copyLarge(in, out, byteRange.getStart(), byteRange.getLength()); 622 } 623 out.flush(); 624 } catch (IOException e) { 625 throw new UncheckedIOException(e); 626 } 627 } 628 629 protected String fixXPath(String xpath) { 630 // Hack for Flash Url wich doesn't support ':' char 631 return xpath == null ? null : xpath.replace(';', ':'); 632 } 633 634 @Override 635 public Blob resolveBlob(DocumentModel doc) { 636 BlobHolderAdapterService blobHolderAdapterService = Framework.getService(BlobHolderAdapterService.class); 637 return blobHolderAdapterService.getBlobHolderAdapter(doc, "download").getBlob(); 638 } 639 640 @Override 641 public Blob resolveBlob(DocumentModel doc, String xpath) { 642 if (xpath == null) { 643 return resolveBlob(doc); 644 } 645 xpath = fixXPath(xpath); 646 Blob blob; 647 if (xpath.startsWith(BLOBHOLDER_PREFIX)) { 648 BlobHolder bh = doc.getAdapter(BlobHolder.class); 649 if (bh == null) { 650 log.debug("Not a BlobHolder"); 651 return null; 652 } 653 String suffix = xpath.substring(BLOBHOLDER_PREFIX.length()); 654 int index; 655 try { 656 index = Integer.parseInt(suffix); 657 } catch (NumberFormatException e) { 658 log.debug(e.getMessage()); 659 return null; 660 } 661 if (!suffix.equals(Integer.toString(index))) { 662 // attempt to use a non-canonical integer, could be used to bypass 663 // a permission function checking just "blobholder:1" and receiving "blobholder:01" 664 log.debug("Non-canonical index: " + suffix); 665 return null; 666 } 667 if (index == 0) { 668 blob = bh.getBlob(); 669 } else { 670 blob = bh.getBlobs().get(index); 671 } 672 } else { 673 if (!xpath.contains(":")) { 674 // attempt to use a xpath not prefix-qualified, could be used to bypass 675 // a permission function checking just "file:content" and receiving "content" 676 log.debug("Non-canonical xpath: " + xpath); 677 return null; 678 } 679 try { 680 blob = (Blob) doc.getPropertyValue(xpath); 681 } catch (PropertyNotFoundException e) { 682 log.debug(e.getMessage()); 683 return null; 684 } 685 } 686 return blob; 687 } 688 689 @Override 690 public boolean checkPermission(DocumentModel doc, String xpath, Blob blob, String reason, 691 Map<String, Serializable> extendedInfos) { 692 List<DownloadPermissionDescriptor> descriptors = registry.getDownloadPermissionDescriptors(); 693 if (descriptors.isEmpty()) { 694 return true; 695 } 696 xpath = fixXPath(xpath); 697 Map<String, Object> context = new HashMap<>(); 698 Map<String, Serializable> ei = extendedInfos == null ? Collections.emptyMap() : extendedInfos; 699 NuxeoPrincipal currentUser = ClientLoginModule.getCurrentPrincipal(); 700 context.put("Document", doc); 701 context.put("XPath", xpath); 702 context.put("Blob", blob); 703 context.put("Reason", reason); 704 context.put("Infos", ei); 705 context.put("Rendition", ei.get("rendition")); 706 context.put("CurrentUser", currentUser); 707 for (DownloadPermissionDescriptor descriptor : descriptors) { 708 ScriptEngine engine = scriptEngineManager.getEngineByName(descriptor.getScriptLanguage()); 709 if (engine == null) { 710 throw new NuxeoException("Engine not found for language: " + descriptor.getScriptLanguage() 711 + " in permission: " + descriptor.getName()); 712 } 713 if (!(engine instanceof Invocable)) { 714 throw new NuxeoException("Engine " + engine.getClass().getName() + " not Invocable for language: " 715 + descriptor.getScriptLanguage() + " in permission: " + descriptor.getName()); 716 } 717 Object result; 718 try { 719 engine.eval(descriptor.getScript()); 720 engine.getBindings(ScriptContext.ENGINE_SCOPE).putAll(context); 721 result = ((Invocable) engine).invokeFunction(RUN_FUNCTION); 722 } catch (NoSuchMethodException e) { 723 throw new NuxeoException("Script does not contain function: " + RUN_FUNCTION + "() in permission: " 724 + descriptor.getName(), e); 725 } catch (ScriptException e) { 726 log.error("Failed to evaluate script: " + descriptor.getName(), e); 727 continue; 728 } 729 if (!(result instanceof Boolean)) { 730 log.error( 731 "Failed to get boolean result from permission: " + descriptor.getName() + " (" + result + ")"); 732 continue; 733 } 734 boolean allow = ((Boolean) result).booleanValue(); 735 if (!allow) { 736 return false; 737 } 738 } 739 return true; 740 } 741 742 /** 743 * Internet Explorer file downloads over SSL do not work with certain HTTP cache control headers 744 * <p> 745 * See http://support.microsoft.com/kb/323308/ 746 * <p> 747 * What is not mentioned in the above Knowledge Base is that "Pragma: no-cache" also breaks download in MSIE over 748 * SSL 749 */ 750 protected void addCacheControlHeaders(HttpServletRequest request, HttpServletResponse response) { 751 String userAgent = request.getHeader("User-Agent"); 752 boolean secure = request.isSecure(); 753 if (!secure) { 754 String nvh = request.getHeader(NUXEO_VIRTUAL_HOST); 755 if (nvh == null) { 756 nvh = Framework.getProperty(VH_PARAM); 757 } 758 if (nvh != null) { 759 secure = nvh.startsWith("https"); 760 } 761 } 762 if (userAgent != null && userAgent.contains("MSIE") && (secure || forceNoCacheOnMSIE())) { 763 String cacheControl = "max-age=15, must-revalidate"; 764 log.debug("Setting Cache-Control: " + cacheControl); 765 response.setHeader("Cache-Control", cacheControl); 766 } 767 } 768 769 protected static boolean forceNoCacheOnMSIE() { 770 // see NXP-7759 771 return Framework.isBooleanPropertyTrue(FORCE_NO_CACHE_ON_MSIE); 772 } 773 774 @Override 775 public void logDownload(DocumentModel doc, String xpath, String filename, String reason, 776 Map<String, Serializable> extendedInfos) { 777 if ("webengine".equals(reason)) { 778 // don't log JSON operation results as downloads 779 return; 780 } 781 EventService eventService = Framework.getService(EventService.class); 782 if (eventService == null) { 783 return; 784 } 785 EventContext ctx; 786 if (doc != null) { 787 CoreSession session = doc.getCoreSession(); 788 Principal principal = session == null ? getPrincipal() : session.getPrincipal(); 789 ctx = new DocumentEventContext(session, principal, doc); 790 ctx.setProperty(CoreEventConstants.REPOSITORY_NAME, doc.getRepositoryName()); 791 ctx.setProperty(CoreEventConstants.SESSION_ID, doc.getSessionId()); 792 } else { 793 ctx = new EventContextImpl(null, getPrincipal()); 794 } 795 Map<String, Serializable> map = new HashMap<>(); 796 map.put("blobXPath", xpath); 797 map.put("blobFilename", filename); 798 map.put("downloadReason", reason); 799 if (extendedInfos != null) { 800 map.putAll(extendedInfos); 801 } 802 ctx.setProperty("extendedInfos", (Serializable) map); 803 ctx.setProperty("comment", filename); 804 Event event = ctx.newEvent(EVENT_NAME); 805 eventService.fireEvent(event); 806 } 807 808 protected static NuxeoPrincipal getPrincipal() { 809 return ClientLoginModule.getCurrentPrincipal(); 810 } 811 812}