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