001/* 002 * (C) Copyright 2015 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 */ 019package org.nuxeo.ecm.core.io.download; 020 021import java.io.IOException; 022import java.io.InputStream; 023import java.io.OutputStream; 024import java.io.Serializable; 025import java.io.UncheckedIOException; 026import java.net.URI; 027import java.security.Principal; 028import java.util.ArrayList; 029import java.util.Collections; 030import java.util.HashMap; 031import java.util.List; 032import java.util.Map; 033import java.util.Objects; 034import java.util.function.Consumer; 035import java.util.function.Supplier; 036 037import javax.script.Invocable; 038import javax.script.ScriptContext; 039import javax.script.ScriptEngine; 040import javax.script.ScriptEngineManager; 041import javax.script.ScriptException; 042import javax.servlet.http.HttpServletRequest; 043import javax.servlet.http.HttpServletResponse; 044 045import org.apache.commons.codec.digest.DigestUtils; 046import org.apache.commons.io.IOUtils; 047import org.apache.commons.lang.StringUtils; 048import org.apache.commons.logging.Log; 049import org.apache.commons.logging.LogFactory; 050import org.nuxeo.common.utils.URIUtils; 051import org.nuxeo.ecm.core.api.Blob; 052import org.nuxeo.ecm.core.api.CoreSession; 053import org.nuxeo.ecm.core.api.DocumentModel; 054import org.nuxeo.ecm.core.api.NuxeoException; 055import org.nuxeo.ecm.core.api.NuxeoPrincipal; 056import org.nuxeo.ecm.core.api.SystemPrincipal; 057import org.nuxeo.ecm.core.api.blobholder.BlobHolder; 058import org.nuxeo.ecm.core.api.event.CoreEventConstants; 059import org.nuxeo.ecm.core.api.local.ClientLoginModule; 060import org.nuxeo.ecm.core.api.model.PropertyNotFoundException; 061import org.nuxeo.ecm.core.blob.BlobManager.UsageHint; 062import org.nuxeo.ecm.core.event.Event; 063import org.nuxeo.ecm.core.event.EventContext; 064import org.nuxeo.ecm.core.event.EventService; 065import org.nuxeo.ecm.core.event.impl.DocumentEventContext; 066import org.nuxeo.ecm.core.event.impl.EventContextImpl; 067import org.nuxeo.runtime.api.Framework; 068import org.nuxeo.runtime.model.ComponentInstance; 069import org.nuxeo.runtime.model.DefaultComponent; 070import org.nuxeo.runtime.model.SimpleContributionRegistry; 071 072/** 073 * This service allows the download of blobs to a HTTP response. 074 * 075 * @since 7.3 076 */ 077public class DownloadServiceImpl extends DefaultComponent implements DownloadService { 078 079 private static final Log log = LogFactory.getLog(DownloadServiceImpl.class); 080 081 protected static final int DOWNLOAD_BUFFER_SIZE = 1024 * 512; 082 083 private static final String NUXEO_VIRTUAL_HOST = "nuxeo-virtual-host"; 084 085 private static final String VH_PARAM = "nuxeo.virtual.host"; 086 087 private static final String FORCE_NO_CACHE_ON_MSIE = "org.nuxeo.download.force.nocache.msie"; 088 089 private static final String XP = "permissions"; 090 091 private static final String REDIRECT_RESOLVER = "redirectResolver"; 092 093 private static final String RUN_FUNCTION = "run"; 094 095 private DownloadPermissionRegistry registry = new DownloadPermissionRegistry(); 096 097 private ScriptEngineManager scriptEngineManager; 098 099 protected RedirectResolver redirectResolver = new DefaultRedirectResolver(); 100 101 protected List<RedirectResolverDescriptor> redirectResolverContributions = new ArrayList<>(); 102 103 public static class DownloadPermissionRegistry extends SimpleContributionRegistry<DownloadPermissionDescriptor> { 104 105 @Override 106 public String getContributionId(DownloadPermissionDescriptor contrib) { 107 return contrib.getName(); 108 } 109 110 @Override 111 public boolean isSupportingMerge() { 112 return true; 113 } 114 115 @Override 116 public DownloadPermissionDescriptor clone(DownloadPermissionDescriptor orig) { 117 return new DownloadPermissionDescriptor(orig); 118 } 119 120 @Override 121 public void merge(DownloadPermissionDescriptor src, DownloadPermissionDescriptor dst) { 122 dst.merge(src); 123 } 124 125 public DownloadPermissionDescriptor getDownloadPermissionDescriptor(String id) { 126 return getCurrentContribution(id); 127 } 128 129 /** Returns descriptors sorted by name. */ 130 public List<DownloadPermissionDescriptor> getDownloadPermissionDescriptors() { 131 List<DownloadPermissionDescriptor> descriptors = new ArrayList<>(currentContribs.values()); 132 Collections.sort(descriptors); 133 return descriptors; 134 } 135 } 136 137 public DownloadServiceImpl() { 138 scriptEngineManager = new ScriptEngineManager(); 139 } 140 141 @Override 142 public void registerContribution(Object contribution, String extensionPoint, ComponentInstance contributor) { 143 if (XP.equals(extensionPoint)) { 144 DownloadPermissionDescriptor descriptor = (DownloadPermissionDescriptor) contribution; 145 registry.addContribution(descriptor); 146 } else if (REDIRECT_RESOLVER.equals(extensionPoint)) { 147 this.redirectResolver = ((RedirectResolverDescriptor) contribution).getObject(); 148 // Save contribution 149 redirectResolverContributions.add((RedirectResolverDescriptor) contribution); 150 } else { 151 throw new UnsupportedOperationException(extensionPoint); 152 } 153 } 154 155 @Override 156 public void unregisterContribution(Object contribution, String extensionPoint, ComponentInstance contributor) { 157 if (XP.equals(extensionPoint)) { 158 DownloadPermissionDescriptor descriptor = (DownloadPermissionDescriptor) contribution; 159 registry.removeContribution(descriptor); 160 } else if (REDIRECT_RESOLVER.equals(extensionPoint)) { 161 redirectResolverContributions.remove(contribution); 162 if (redirectResolverContributions.size() == 0) { 163 // If no more custom contribution go back to the default one 164 redirectResolver = new DefaultRedirectResolver(); 165 } else { 166 // Go back to the last contribution added 167 redirectResolver = redirectResolverContributions.get(redirectResolverContributions.size() - 1) 168 .getObject(); 169 } 170 } else { 171 throw new UnsupportedOperationException(extensionPoint); 172 } 173 } 174 175 @Override 176 public String getDownloadUrl(DocumentModel doc, String xpath, String filename) { 177 return getDownloadUrl(doc.getRepositoryName(), doc.getId(), xpath, filename); 178 } 179 180 @Override 181 public String getDownloadUrl(String repositoryName, String docId, String xpath, String filename) { 182 StringBuilder sb = new StringBuilder(); 183 sb.append(NXFILE); 184 sb.append("/"); 185 sb.append(repositoryName); 186 sb.append("/"); 187 sb.append(docId); 188 if (xpath != null) { 189 sb.append("/"); 190 sb.append(xpath); 191 if (filename != null) { 192 sb.append("/"); 193 sb.append(URIUtils.quoteURIPathComponent(filename, true)); 194 } 195 } 196 return sb.toString(); 197 } 198 199 @Override 200 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, 201 Blob blob, String filename, String reason) throws IOException { 202 downloadBlob(request, response, doc, xpath, blob, filename, reason, null); 203 } 204 205 @Override 206 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, 207 Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos) throws IOException { 208 downloadBlob(request, response, doc, xpath, blob, filename, reason, extendedInfos, null); 209 } 210 211 @Override 212 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, 213 Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos, Boolean inline) 214 throws IOException { 215 if (blob == null) { 216 if (doc == null || xpath == null) { 217 throw new NuxeoException("No blob or doc xpath"); 218 } 219 blob = resolveBlob(doc, xpath); 220 if (blob == null) { 221 response.sendError(HttpServletResponse.SC_NOT_FOUND, "No blob found"); 222 return; 223 } 224 } 225 final Blob fblob = blob; 226 downloadBlob(request, response, doc, xpath, blob, filename, reason, extendedInfos, inline, 227 byteRange -> transferBlobWithByteRange(fblob, byteRange, response)); 228 } 229 230 @Override 231 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, 232 Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos, Boolean inline, 233 Consumer<ByteRange> blobTransferer) throws IOException { 234 Objects.requireNonNull(blob); 235 // check blob permissions 236 if (!checkPermission(doc, xpath, blob, reason, extendedInfos)) { 237 response.sendError(HttpServletResponse.SC_FORBIDDEN, "Permission denied"); 238 return; 239 } 240 241 // check Blob Manager download link 242 URI uri = redirectResolver.getURI(blob, UsageHint.DOWNLOAD, request); 243 if (uri != null) { 244 try { 245 Map<String, Serializable> ei = new HashMap<>(); 246 if (extendedInfos != null) { 247 ei.putAll(extendedInfos); 248 } 249 ei.put("redirect", uri.toString()); 250 logDownload(doc, xpath, filename, reason, ei); 251 response.sendRedirect(uri.toString()); 252 } catch (IOException ioe) { 253 DownloadHelper.handleClientDisconnect(ioe); 254 } 255 return; 256 } 257 258 try { 259 String digest = blob.getDigest(); 260 if (digest == null) { 261 digest = DigestUtils.md5Hex(blob.getStream()); 262 } 263 String etag = '"' + digest + '"'; // with quotes per RFC7232 2.3 264 response.setHeader("ETag", etag); // re-send even on SC_NOT_MODIFIED 265 addCacheControlHeaders(request, response); 266 267 String ifNoneMatch = request.getHeader("If-None-Match"); 268 if (ifNoneMatch != null) { 269 boolean match = false; 270 if (ifNoneMatch.equals("*")) { 271 match = true; 272 } else { 273 for (String previousEtag : StringUtils.split(ifNoneMatch, ", ")) { 274 if (previousEtag.equals(etag)) { 275 match = true; 276 break; 277 } 278 } 279 } 280 if (match) { 281 String method = request.getMethod(); 282 if (method.equals("GET") || method.equals("HEAD")) { 283 response.sendError(HttpServletResponse.SC_NOT_MODIFIED); 284 } else { 285 // per RFC7232 3.2 286 response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); 287 } 288 return; 289 } 290 } 291 292 // regular processing 293 294 if (StringUtils.isBlank(filename)) { 295 filename = StringUtils.defaultIfBlank(blob.getFilename(), "file"); 296 } 297 String contentDisposition = DownloadHelper.getRFC2231ContentDisposition(request, filename, inline); 298 response.setHeader("Content-Disposition", contentDisposition); 299 response.setContentType(blob.getMimeType()); 300 if (blob.getEncoding() != null) { 301 response.setCharacterEncoding(blob.getEncoding()); 302 } 303 304 long length = blob.getLength(); 305 response.setHeader("Accept-Ranges", "bytes"); 306 String range = request.getHeader("Range"); 307 ByteRange byteRange; 308 if (StringUtils.isBlank(range)) { 309 byteRange = null; 310 } else { 311 byteRange = DownloadHelper.parseRange(range, length); 312 if (byteRange == null) { 313 log.error("Invalid byte range received: " + range); 314 } else { 315 response.setHeader("Content-Range", "bytes " + byteRange.getStart() + "-" + byteRange.getEnd() 316 + "/" + length); 317 response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); 318 } 319 } 320 long contentLength = byteRange == null ? length : byteRange.getLength(); 321 if (contentLength < Integer.MAX_VALUE) { 322 response.setContentLength((int) contentLength); 323 } 324 325 logDownload(doc, xpath, filename, reason, extendedInfos); 326 327 // execute the final download 328 blobTransferer.accept(byteRange); 329 } catch (UncheckedIOException e) { 330 DownloadHelper.handleClientDisconnect(e.getCause()); 331 } catch (IOException ioe) { 332 DownloadHelper.handleClientDisconnect(ioe); 333 } 334 } 335 336 protected void transferBlobWithByteRange(Blob blob, ByteRange byteRange, HttpServletResponse response) 337 throws UncheckedIOException { 338 transferBlobWithByteRange(blob, byteRange, () -> { 339 try { 340 return response.getOutputStream(); 341 } catch (IOException e) { 342 throw new UncheckedIOException(e); 343 } 344 }); 345 try { 346 response.flushBuffer(); 347 } catch (IOException e) { 348 throw new UncheckedIOException(e); 349 } 350 } 351 352 @Override 353 public void transferBlobWithByteRange(Blob blob, ByteRange byteRange, Supplier<OutputStream> outputStreamSupplier) 354 throws UncheckedIOException { 355 try (InputStream in = blob.getStream()) { 356 @SuppressWarnings("resource") 357 OutputStream out = outputStreamSupplier.get(); // not ours to close 358 BufferingServletOutputStream.stopBuffering(out); 359 if (byteRange == null) { 360 IOUtils.copy(in, out); 361 } else { 362 IOUtils.copyLarge(in, out, byteRange.getStart(), byteRange.getLength()); 363 } 364 out.flush(); 365 } catch (IOException e) { 366 throw new UncheckedIOException(e); 367 } 368 } 369 370 protected String fixXPath(String xpath) { 371 // Hack for Flash Url wich doesn't support ':' char 372 return xpath == null ? null : xpath.replace(';', ':'); 373 } 374 375 @Override 376 public Blob resolveBlob(DocumentModel doc, String xpath) { 377 xpath = fixXPath(xpath); 378 Blob blob; 379 if (xpath.startsWith(BLOBHOLDER_PREFIX)) { 380 BlobHolder bh = doc.getAdapter(BlobHolder.class); 381 if (bh == null) { 382 log.debug("Not a BlobHolder"); 383 return null; 384 } 385 String suffix = xpath.substring(BLOBHOLDER_PREFIX.length()); 386 int index; 387 try { 388 index = Integer.parseInt(suffix); 389 } catch (NumberFormatException e) { 390 log.debug(e.getMessage()); 391 return null; 392 } 393 if (!suffix.equals(Integer.toString(index))) { 394 // attempt to use a non-canonical integer, could be used to bypass 395 // a permission function checking just "blobholder:1" and receiving "blobholder:01" 396 log.debug("Non-canonical index: " + suffix); 397 return null; 398 } 399 if (index == 0) { 400 blob = bh.getBlob(); 401 } else { 402 blob = bh.getBlobs().get(index); 403 } 404 } else { 405 if (!xpath.contains(":")) { 406 // attempt to use a xpath not prefix-qualified, could be used to bypass 407 // a permission function checking just "file:content" and receiving "content" 408 log.debug("Non-canonical xpath: " + xpath); 409 return null; 410 } 411 try { 412 blob = (Blob) doc.getPropertyValue(xpath); 413 } catch (PropertyNotFoundException e) { 414 log.debug(e.getMessage()); 415 return null; 416 } 417 } 418 return blob; 419 } 420 421 @Override 422 public boolean checkPermission(DocumentModel doc, String xpath, Blob blob, String reason, 423 Map<String, Serializable> extendedInfos) { 424 List<DownloadPermissionDescriptor> descriptors = registry.getDownloadPermissionDescriptors(); 425 if (descriptors.isEmpty()) { 426 return true; 427 } 428 xpath = fixXPath(xpath); 429 Map<String, Object> context = new HashMap<>(); 430 Map<String, Serializable> ei = extendedInfos == null ? Collections.emptyMap() : extendedInfos; 431 NuxeoPrincipal currentUser = ClientLoginModule.getCurrentPrincipal(); 432 context.put("Document", doc); 433 context.put("XPath", xpath); 434 context.put("Blob", blob); 435 context.put("Reason", reason); 436 context.put("Infos", ei); 437 context.put("Rendition", ei.get("rendition")); 438 context.put("CurrentUser", currentUser); 439 for (DownloadPermissionDescriptor descriptor : descriptors) { 440 ScriptEngine engine = scriptEngineManager.getEngineByName(descriptor.getScriptLanguage()); 441 if (engine == null) { 442 throw new NuxeoException("Engine not found for language: " + descriptor.getScriptLanguage() 443 + " in permission: " + descriptor.getName()); 444 } 445 if (!(engine instanceof Invocable)) { 446 throw new NuxeoException("Engine " + engine.getClass().getName() + " not Invocable for language: " 447 + descriptor.getScriptLanguage() + " in permission: " + descriptor.getName()); 448 } 449 Object result; 450 try { 451 engine.eval(descriptor.getScript()); 452 engine.getBindings(ScriptContext.ENGINE_SCOPE).putAll(context); 453 result = ((Invocable) engine).invokeFunction(RUN_FUNCTION); 454 } catch (NoSuchMethodException e) { 455 throw new NuxeoException("Script does not contain function: " + RUN_FUNCTION + "() in permission: " 456 + descriptor.getName(), e); 457 } catch (ScriptException e) { 458 log.error("Failed to evaluate script: " + descriptor.getName(), e); 459 continue; 460 } 461 if (!(result instanceof Boolean)) { 462 log.error("Failed to get boolean result from permission: " + descriptor.getName() + " (" + result + ")"); 463 continue; 464 } 465 boolean allow = ((Boolean) result).booleanValue(); 466 if (!allow) { 467 return false; 468 } 469 } 470 return true; 471 } 472 473 /** 474 * Internet Explorer file downloads over SSL do not work with certain HTTP cache control headers 475 * <p> 476 * See http://support.microsoft.com/kb/323308/ 477 * <p> 478 * What is not mentioned in the above Knowledge Base is that "Pragma: no-cache" also breaks download in MSIE over 479 * SSL 480 */ 481 protected void addCacheControlHeaders(HttpServletRequest request, HttpServletResponse response) { 482 String userAgent = request.getHeader("User-Agent"); 483 boolean secure = request.isSecure(); 484 if (!secure) { 485 String nvh = request.getHeader(NUXEO_VIRTUAL_HOST); 486 if (nvh == null) { 487 nvh = Framework.getProperty(VH_PARAM); 488 } 489 if (nvh != null) { 490 secure = nvh.startsWith("https"); 491 } 492 } 493 String cacheControl; 494 if (userAgent != null && userAgent.contains("MSIE") && (secure || forceNoCacheOnMSIE())) { 495 cacheControl = "max-age=15, must-revalidate"; 496 } else { 497 cacheControl = "private, must-revalidate"; 498 response.setHeader("Pragma", "no-cache"); 499 response.setDateHeader("Expires", 0); 500 } 501 log.debug("Setting Cache-Control: " + cacheControl); 502 response.setHeader("Cache-Control", cacheControl); 503 } 504 505 protected static boolean forceNoCacheOnMSIE() { 506 // see NXP-7759 507 return Framework.isBooleanPropertyTrue(FORCE_NO_CACHE_ON_MSIE); 508 } 509 510 @Override 511 public void logDownload(DocumentModel doc, String xpath, String filename, String reason, 512 Map<String, Serializable> extendedInfos) { 513 EventService eventService = Framework.getService(EventService.class); 514 if (eventService == null) { 515 return; 516 } 517 EventContext ctx; 518 if (doc != null) { 519 @SuppressWarnings("resource") 520 CoreSession session = doc.getCoreSession(); 521 Principal principal = session == null ? getPrincipal() : session.getPrincipal(); 522 ctx = new DocumentEventContext(session, principal, doc); 523 ctx.setProperty(CoreEventConstants.REPOSITORY_NAME, doc.getRepositoryName()); 524 ctx.setProperty(CoreEventConstants.SESSION_ID, doc.getSessionId()); 525 } else { 526 ctx = new EventContextImpl(null, getPrincipal()); 527 } 528 Map<String, Serializable> map = new HashMap<>(); 529 map.put("blobXPath", xpath); 530 map.put("blobFilename", filename); 531 map.put("downloadReason", reason); 532 if (extendedInfos != null) { 533 map.putAll(extendedInfos); 534 } 535 ctx.setProperty("extendedInfos", (Serializable) map); 536 ctx.setProperty("comment", filename); 537 Event event = ctx.newEvent(EVENT_NAME); 538 eventService.fireEvent(event); 539 } 540 541 protected static NuxeoPrincipal getPrincipal() { 542 NuxeoPrincipal principal = ClientLoginModule.getCurrentPrincipal(); 543 if (principal == null) { 544 if (!Framework.isTestModeSet()) { 545 throw new NuxeoException("Missing security context, login() not done"); 546 } 547 principal = new SystemPrincipal(null); 548 } 549 return principal; 550 } 551 552}