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