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