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