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