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.io.IOUtils; 046import org.apache.commons.lang.StringUtils; 047import org.apache.commons.logging.Log; 048import org.apache.commons.logging.LogFactory; 049import org.nuxeo.common.utils.URIUtils; 050import org.nuxeo.ecm.core.api.Blob; 051import org.nuxeo.ecm.core.api.CoreSession; 052import org.nuxeo.ecm.core.api.DocumentModel; 053import org.nuxeo.ecm.core.api.NuxeoException; 054import org.nuxeo.ecm.core.api.NuxeoPrincipal; 055import org.nuxeo.ecm.core.api.SystemPrincipal; 056import org.nuxeo.ecm.core.api.blobholder.BlobHolder; 057import org.nuxeo.ecm.core.api.event.CoreEventConstants; 058import org.nuxeo.ecm.core.api.local.ClientLoginModule; 059import org.nuxeo.ecm.core.api.model.PropertyNotFoundException; 060import org.nuxeo.ecm.core.blob.BlobManager; 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<RedirectResolverDescriptor>(); 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((RedirectResolverDescriptor) 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).getObject(); 168 } 169 } else { 170 throw new UnsupportedOperationException(extensionPoint); 171 } 172 } 173 174 @Override 175 public String getDownloadUrl(DocumentModel doc, String xpath, String filename) { 176 return getDownloadUrl(doc.getRepositoryName(), doc.getId(), xpath, filename); 177 } 178 179 @Override 180 public String getDownloadUrl(String repositoryName, String docId, String xpath, String filename) { 181 StringBuilder sb = new StringBuilder(); 182 sb.append(NXFILE); 183 sb.append("/"); 184 sb.append(repositoryName); 185 sb.append("/"); 186 sb.append(docId); 187 if (xpath != null) { 188 sb.append("/"); 189 sb.append(xpath); 190 if (filename != null) { 191 sb.append("/"); 192 sb.append(URIUtils.quoteURIPathComponent(filename, true)); 193 } 194 } 195 return sb.toString(); 196 } 197 198 @Override 199 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, 200 Blob blob, String filename, String reason) throws IOException { 201 downloadBlob(request, response, doc, xpath, blob, filename, reason, null); 202 } 203 204 @Override 205 public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, 206 Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos) 207 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 etag = '"' + blob.getDigest() + '"'; // with quotes per RFC7232 2.3 260 response.setHeader("ETag", etag); // re-send even on SC_NOT_MODIFIED 261 addCacheControlHeaders(request, response); 262 263 String ifNoneMatch = request.getHeader("If-None-Match"); 264 if (ifNoneMatch != null) { 265 boolean match = false; 266 if (ifNoneMatch.equals("*")) { 267 match = true; 268 } else { 269 for (String previousEtag : StringUtils.split(ifNoneMatch, ", ")) { 270 if (previousEtag.equals(etag)) { 271 match = true; 272 break; 273 } 274 } 275 } 276 if (match) { 277 String method = request.getMethod(); 278 if (method.equals("GET") || method.equals("HEAD")) { 279 response.sendError(HttpServletResponse.SC_NOT_MODIFIED); 280 } else { 281 // per RFC7232 3.2 282 response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); 283 } 284 return; 285 } 286 } 287 288 // regular processing 289 290 if (StringUtils.isBlank(filename)) { 291 filename = StringUtils.defaultIfBlank(blob.getFilename(), "file"); 292 } 293 String contentDisposition = DownloadHelper.getRFC2231ContentDisposition(request, filename, inline); 294 response.setHeader("Content-Disposition", contentDisposition); 295 response.setContentType(blob.getMimeType()); 296 if (blob.getEncoding() != null) { 297 response.setCharacterEncoding(blob.getEncoding()); 298 } 299 300 long length = blob.getLength(); 301 response.setHeader("Accept-Ranges", "bytes"); 302 String range = request.getHeader("Range"); 303 ByteRange byteRange; 304 if (StringUtils.isBlank(range)) { 305 byteRange = null; 306 } else { 307 byteRange = DownloadHelper.parseRange(range, length); 308 if (byteRange == null) { 309 log.error("Invalid byte range received: " + range); 310 } else { 311 response.setHeader("Content-Range", 312 "bytes " + byteRange.getStart() + "-" + byteRange.getEnd() + "/" + length); 313 response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); 314 } 315 } 316 long contentLength = byteRange == null ? length : byteRange.getLength(); 317 if (contentLength < Integer.MAX_VALUE) { 318 response.setContentLength((int) contentLength); 319 } 320 321 logDownload(doc, xpath, filename, reason, extendedInfos); 322 323 // execute the final download 324 blobTransferer.accept(byteRange); 325 } catch (UncheckedIOException e) { 326 DownloadHelper.handleClientDisconnect(e.getCause()); 327 } catch (IOException ioe) { 328 DownloadHelper.handleClientDisconnect(ioe); 329 } 330 } 331 332 protected void transferBlobWithByteRange(Blob blob, ByteRange byteRange, HttpServletResponse response) 333 throws UncheckedIOException { 334 transferBlobWithByteRange(blob, byteRange, () -> { 335 try { 336 return response.getOutputStream(); 337 } catch (IOException e) { 338 throw new UncheckedIOException(e); 339 } 340 }); 341 try { 342 response.flushBuffer(); 343 } catch (IOException e) { 344 throw new UncheckedIOException(e); 345 } 346 } 347 348 @Override 349 public void transferBlobWithByteRange(Blob blob, ByteRange byteRange, Supplier<OutputStream> outputStreamSupplier) 350 throws UncheckedIOException { 351 try (InputStream in = blob.getStream()) { 352 @SuppressWarnings("resource") 353 OutputStream out = outputStreamSupplier.get(); // not ours to close 354 BufferingServletOutputStream.stopBuffering(out); 355 if (byteRange == null) { 356 IOUtils.copy(in, out); 357 } else { 358 IOUtils.copyLarge(in, out, byteRange.getStart(), byteRange.getLength()); 359 } 360 out.flush(); 361 } catch (IOException e) { 362 throw new UncheckedIOException(e); 363 } 364 } 365 366 protected String fixXPath(String xpath) { 367 // Hack for Flash Url wich doesn't support ':' char 368 return xpath == null ? null : xpath.replace(';', ':'); 369 } 370 371 @Override 372 public Blob resolveBlob(DocumentModel doc, String xpath) { 373 xpath = fixXPath(xpath); 374 Blob blob; 375 if (xpath.startsWith(BLOBHOLDER_PREFIX)) { 376 BlobHolder bh = doc.getAdapter(BlobHolder.class); 377 if (bh == null) { 378 log.debug("Not a BlobHolder"); 379 return null; 380 } 381 String suffix = xpath.substring(BLOBHOLDER_PREFIX.length()); 382 int index; 383 try { 384 index = Integer.parseInt(suffix); 385 } catch (NumberFormatException e) { 386 log.debug(e.getMessage()); 387 return null; 388 } 389 if (!suffix.equals(Integer.toString(index))) { 390 // attempt to use a non-canonical integer, could be used to bypass 391 // a permission function checking just "blobholder:1" and receiving "blobholder:01" 392 log.debug("Non-canonical index: " + suffix); 393 return null; 394 } 395 if (index == 0) { 396 blob = bh.getBlob(); 397 } else { 398 blob = bh.getBlobs().get(index); 399 } 400 } else { 401 if (!xpath.contains(":")) { 402 // attempt to use a xpath not prefix-qualified, could be used to bypass 403 // a permission function checking just "file:content" and receiving "content" 404 log.debug("Non-canonical xpath: " + xpath); 405 return null; 406 } 407 try { 408 blob = (Blob) doc.getPropertyValue(xpath); 409 } catch (PropertyNotFoundException e) { 410 log.debug(e.getMessage()); 411 return null; 412 } 413 } 414 return blob; 415 } 416 417 @Override 418 public boolean checkPermission(DocumentModel doc, String xpath, Blob blob, String reason, 419 Map<String, Serializable> extendedInfos) { 420 List<DownloadPermissionDescriptor> descriptors = registry.getDownloadPermissionDescriptors(); 421 if (descriptors.isEmpty()) { 422 return true; 423 } 424 xpath = fixXPath(xpath); 425 Map<String, Object> context = new HashMap<>(); 426 Map<String, Serializable> ei = extendedInfos == null ? Collections.emptyMap() : extendedInfos; 427 NuxeoPrincipal currentUser = ClientLoginModule.getCurrentPrincipal(); 428 context.put("Document", doc); 429 context.put("XPath", xpath); 430 context.put("Blob", blob); 431 context.put("Reason", reason); 432 context.put("Infos", ei); 433 context.put("Rendition", ei.get("rendition")); 434 context.put("CurrentUser", currentUser); 435 for (DownloadPermissionDescriptor descriptor : descriptors) { 436 ScriptEngine engine = scriptEngineManager.getEngineByName(descriptor.getScriptLanguage()); 437 if (engine == null) { 438 throw new NuxeoException("Engine not found for language: " + descriptor.getScriptLanguage() 439 + " in permission: " + descriptor.getName()); 440 } 441 if (!(engine instanceof Invocable)) { 442 throw new NuxeoException("Engine " + engine.getClass().getName() + " not Invocable for language: " 443 + descriptor.getScriptLanguage() + " in permission: " + descriptor.getName()); 444 } 445 Object result; 446 try { 447 engine.eval(descriptor.getScript()); 448 engine.getBindings(ScriptContext.ENGINE_SCOPE).putAll(context); 449 result = ((Invocable) engine).invokeFunction(RUN_FUNCTION); 450 } catch (NoSuchMethodException e) { 451 throw new NuxeoException("Script does not contain function: " + RUN_FUNCTION + "() in permission: " 452 + descriptor.getName(), e); 453 } catch (ScriptException e) { 454 log.error("Failed to evaluate script: " + descriptor.getName(), e); 455 continue; 456 } 457 if (!(result instanceof Boolean)) { 458 log.error("Failed to get boolean result from permission: " + descriptor.getName() + " (" + result + ")"); 459 continue; 460 } 461 boolean allow = ((Boolean) result).booleanValue(); 462 if (!allow) { 463 return false; 464 } 465 } 466 return true; 467 } 468 469 /** 470 * Internet Explorer file downloads over SSL do not work with certain HTTP cache control headers 471 * <p> 472 * See http://support.microsoft.com/kb/323308/ 473 * <p> 474 * What is not mentioned in the above Knowledge Base is that "Pragma: no-cache" also breaks download in MSIE over 475 * SSL 476 */ 477 protected void addCacheControlHeaders(HttpServletRequest request, HttpServletResponse response) { 478 String userAgent = request.getHeader("User-Agent"); 479 boolean secure = request.isSecure(); 480 if (!secure) { 481 String nvh = request.getHeader(NUXEO_VIRTUAL_HOST); 482 if (nvh == null) { 483 nvh = Framework.getProperty(VH_PARAM); 484 } 485 if (nvh != null) { 486 secure = nvh.startsWith("https"); 487 } 488 } 489 String cacheControl; 490 if (userAgent != null && userAgent.contains("MSIE") && (secure || forceNoCacheOnMSIE())) { 491 cacheControl = "max-age=15, must-revalidate"; 492 } else { 493 cacheControl = "private, must-revalidate"; 494 response.setHeader("Pragma", "no-cache"); 495 response.setDateHeader("Expires", 0); 496 } 497 log.debug("Setting Cache-Control: " + cacheControl); 498 response.setHeader("Cache-Control", cacheControl); 499 } 500 501 protected static boolean forceNoCacheOnMSIE() { 502 // see NXP-7759 503 return Framework.isBooleanPropertyTrue(FORCE_NO_CACHE_ON_MSIE); 504 } 505 506 @Override 507 public void logDownload(DocumentModel doc, String xpath, String filename, String reason, 508 Map<String, Serializable> extendedInfos) { 509 EventService eventService = Framework.getService(EventService.class); 510 if (eventService == null) { 511 return; 512 } 513 EventContext ctx; 514 if (doc != null) { 515 @SuppressWarnings("resource") 516 CoreSession session = doc.getCoreSession(); 517 Principal principal = session == null ? getPrincipal() : session.getPrincipal(); 518 ctx = new DocumentEventContext(session, principal, doc); 519 ctx.setProperty(CoreEventConstants.REPOSITORY_NAME, doc.getRepositoryName()); 520 ctx.setProperty(CoreEventConstants.SESSION_ID, doc.getSessionId()); 521 } else { 522 ctx = new EventContextImpl(null, getPrincipal()); 523 } 524 Map<String, Serializable> map = new HashMap<>(); 525 map.put("blobXPath", xpath); 526 map.put("blobFilename", filename); 527 map.put("downloadReason", reason); 528 if (extendedInfos != null) { 529 map.putAll(extendedInfos); 530 } 531 ctx.setProperty("extendedInfos", (Serializable) map); 532 ctx.setProperty("comment", filename); 533 Event event = ctx.newEvent(EVENT_NAME); 534 eventService.fireEvent(event); 535 } 536 537 protected static NuxeoPrincipal getPrincipal() { 538 NuxeoPrincipal principal = ClientLoginModule.getCurrentPrincipal(); 539 if (principal == null) { 540 if (!Framework.isTestModeSet()) { 541 throw new NuxeoException("Missing security context, login() not done"); 542 } 543 principal = new SystemPrincipal(null); 544 } 545 return principal; 546 } 547 548}