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