Java tutorial
/* * (C) Copyright 2015 Nuxeo SA (http://nuxeo.com/) and others. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * Contributors: * Florent Guillaume */ package org.nuxeo.ecm.core.io.download; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.Serializable; import java.io.UncheckedIOException; import java.net.URI; import java.security.Principal; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Consumer; import java.util.function.Supplier; import javax.script.Invocable; import javax.script.ScriptContext; import javax.script.ScriptEngine; import javax.script.ScriptEngineManager; import javax.script.ScriptException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.nuxeo.common.utils.URIUtils; import org.nuxeo.ecm.core.api.Blob; import org.nuxeo.ecm.core.api.CoreSession; import org.nuxeo.ecm.core.api.DocumentModel; import org.nuxeo.ecm.core.api.NuxeoException; import org.nuxeo.ecm.core.api.NuxeoPrincipal; import org.nuxeo.ecm.core.api.SystemPrincipal; import org.nuxeo.ecm.core.api.blobholder.BlobHolder; import org.nuxeo.ecm.core.api.event.CoreEventConstants; import org.nuxeo.ecm.core.api.local.ClientLoginModule; import org.nuxeo.ecm.core.api.model.PropertyNotFoundException; import org.nuxeo.ecm.core.blob.BlobManager.UsageHint; import org.nuxeo.ecm.core.event.Event; import org.nuxeo.ecm.core.event.EventContext; import org.nuxeo.ecm.core.event.EventService; import org.nuxeo.ecm.core.event.impl.DocumentEventContext; import org.nuxeo.ecm.core.event.impl.EventContextImpl; import org.nuxeo.runtime.api.Framework; import org.nuxeo.runtime.model.ComponentInstance; import org.nuxeo.runtime.model.DefaultComponent; import org.nuxeo.runtime.model.SimpleContributionRegistry; /** * This service allows the download of blobs to a HTTP response. * * @since 7.3 */ public class DownloadServiceImpl extends DefaultComponent implements DownloadService { private static final Log log = LogFactory.getLog(DownloadServiceImpl.class); protected static final int DOWNLOAD_BUFFER_SIZE = 1024 * 512; private static final String NUXEO_VIRTUAL_HOST = "nuxeo-virtual-host"; private static final String VH_PARAM = "nuxeo.virtual.host"; private static final String FORCE_NO_CACHE_ON_MSIE = "org.nuxeo.download.force.nocache.msie"; private static final String XP = "permissions"; private static final String REDIRECT_RESOLVER = "redirectResolver"; private static final String RUN_FUNCTION = "run"; private DownloadPermissionRegistry registry = new DownloadPermissionRegistry(); private ScriptEngineManager scriptEngineManager; protected RedirectResolver redirectResolver = new DefaultRedirectResolver(); protected List<RedirectResolverDescriptor> redirectResolverContributions = new ArrayList<>(); public static class DownloadPermissionRegistry extends SimpleContributionRegistry<DownloadPermissionDescriptor> { @Override public String getContributionId(DownloadPermissionDescriptor contrib) { return contrib.getName(); } @Override public boolean isSupportingMerge() { return true; } @Override public DownloadPermissionDescriptor clone(DownloadPermissionDescriptor orig) { return new DownloadPermissionDescriptor(orig); } @Override public void merge(DownloadPermissionDescriptor src, DownloadPermissionDescriptor dst) { dst.merge(src); } public DownloadPermissionDescriptor getDownloadPermissionDescriptor(String id) { return getCurrentContribution(id); } /** Returns descriptors sorted by name. */ public List<DownloadPermissionDescriptor> getDownloadPermissionDescriptors() { List<DownloadPermissionDescriptor> descriptors = new ArrayList<>(currentContribs.values()); Collections.sort(descriptors); return descriptors; } } public DownloadServiceImpl() { scriptEngineManager = new ScriptEngineManager(); } @Override public void registerContribution(Object contribution, String extensionPoint, ComponentInstance contributor) { if (XP.equals(extensionPoint)) { DownloadPermissionDescriptor descriptor = (DownloadPermissionDescriptor) contribution; registry.addContribution(descriptor); } else if (REDIRECT_RESOLVER.equals(extensionPoint)) { this.redirectResolver = ((RedirectResolverDescriptor) contribution).getObject(); // Save contribution redirectResolverContributions.add((RedirectResolverDescriptor) contribution); } else { throw new UnsupportedOperationException(extensionPoint); } } @Override public void unregisterContribution(Object contribution, String extensionPoint, ComponentInstance contributor) { if (XP.equals(extensionPoint)) { DownloadPermissionDescriptor descriptor = (DownloadPermissionDescriptor) contribution; registry.removeContribution(descriptor); } else if (REDIRECT_RESOLVER.equals(extensionPoint)) { redirectResolverContributions.remove(contribution); if (redirectResolverContributions.size() == 0) { // If no more custom contribution go back to the default one redirectResolver = new DefaultRedirectResolver(); } else { // Go back to the last contribution added redirectResolver = redirectResolverContributions.get(redirectResolverContributions.size() - 1) .getObject(); } } else { throw new UnsupportedOperationException(extensionPoint); } } @Override public String getDownloadUrl(DocumentModel doc, String xpath, String filename) { return getDownloadUrl(doc.getRepositoryName(), doc.getId(), xpath, filename); } @Override public String getDownloadUrl(String repositoryName, String docId, String xpath, String filename) { StringBuilder sb = new StringBuilder(); sb.append(NXFILE); sb.append("/"); sb.append(repositoryName); sb.append("/"); sb.append(docId); if (xpath != null) { sb.append("/"); sb.append(xpath); if (filename != null) { sb.append("/"); sb.append(URIUtils.quoteURIPathComponent(filename, true)); } } return sb.toString(); } @Override public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, Blob blob, String filename, String reason) throws IOException { downloadBlob(request, response, doc, xpath, blob, filename, reason, null); } @Override public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos) throws IOException { downloadBlob(request, response, doc, xpath, blob, filename, reason, extendedInfos, null); } @Override public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos, Boolean inline) throws IOException { if (blob == null) { if (doc == null || xpath == null) { throw new NuxeoException("No blob or doc xpath"); } blob = resolveBlob(doc, xpath); if (blob == null) { response.sendError(HttpServletResponse.SC_NOT_FOUND, "No blob found"); return; } } final Blob fblob = blob; downloadBlob(request, response, doc, xpath, blob, filename, reason, extendedInfos, inline, byteRange -> transferBlobWithByteRange(fblob, byteRange, response)); } @Override public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath, Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos, Boolean inline, Consumer<ByteRange> blobTransferer) throws IOException { Objects.requireNonNull(blob); // check blob permissions if (!checkPermission(doc, xpath, blob, reason, extendedInfos)) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "Permission denied"); return; } // check Blob Manager download link URI uri = redirectResolver.getURI(blob, UsageHint.DOWNLOAD, request); if (uri != null) { try { Map<String, Serializable> ei = new HashMap<>(); if (extendedInfos != null) { ei.putAll(extendedInfos); } ei.put("redirect", uri.toString()); logDownload(doc, xpath, filename, reason, ei); response.sendRedirect(uri.toString()); } catch (IOException ioe) { DownloadHelper.handleClientDisconnect(ioe); } return; } try { String digest = blob.getDigest(); if (digest == null) { digest = DigestUtils.md5Hex(blob.getStream()); } String etag = '"' + digest + '"'; // with quotes per RFC7232 2.3 response.setHeader("ETag", etag); // re-send even on SC_NOT_MODIFIED addCacheControlHeaders(request, response); String ifNoneMatch = request.getHeader("If-None-Match"); if (ifNoneMatch != null) { boolean match = false; if (ifNoneMatch.equals("*")) { match = true; } else { for (String previousEtag : StringUtils.split(ifNoneMatch, ", ")) { if (previousEtag.equals(etag)) { match = true; break; } } } if (match) { String method = request.getMethod(); if (method.equals("GET") || method.equals("HEAD")) { response.sendError(HttpServletResponse.SC_NOT_MODIFIED); } else { // per RFC7232 3.2 response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); } return; } } // regular processing if (StringUtils.isBlank(filename)) { filename = StringUtils.defaultIfBlank(blob.getFilename(), "file"); } String contentDisposition = DownloadHelper.getRFC2231ContentDisposition(request, filename, inline); response.setHeader("Content-Disposition", contentDisposition); response.setContentType(blob.getMimeType()); if (blob.getEncoding() != null) { response.setCharacterEncoding(blob.getEncoding()); } long length = blob.getLength(); response.setHeader("Accept-Ranges", "bytes"); String range = request.getHeader("Range"); ByteRange byteRange; if (StringUtils.isBlank(range)) { byteRange = null; } else { byteRange = DownloadHelper.parseRange(range, length); if (byteRange == null) { log.error("Invalid byte range received: " + range); } else { response.setHeader("Content-Range", "bytes " + byteRange.getStart() + "-" + byteRange.getEnd() + "/" + length); response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); } } long contentLength = byteRange == null ? length : byteRange.getLength(); if (contentLength < Integer.MAX_VALUE) { response.setContentLength((int) contentLength); } logDownload(doc, xpath, filename, reason, extendedInfos); // execute the final download blobTransferer.accept(byteRange); } catch (UncheckedIOException e) { DownloadHelper.handleClientDisconnect(e.getCause()); } catch (IOException ioe) { DownloadHelper.handleClientDisconnect(ioe); } } protected void transferBlobWithByteRange(Blob blob, ByteRange byteRange, HttpServletResponse response) throws UncheckedIOException { transferBlobWithByteRange(blob, byteRange, () -> { try { return response.getOutputStream(); } catch (IOException e) { throw new UncheckedIOException(e); } }); try { response.flushBuffer(); } catch (IOException e) { throw new UncheckedIOException(e); } } @Override public void transferBlobWithByteRange(Blob blob, ByteRange byteRange, Supplier<OutputStream> outputStreamSupplier) throws UncheckedIOException { try (InputStream in = blob.getStream()) { @SuppressWarnings("resource") OutputStream out = outputStreamSupplier.get(); // not ours to close BufferingServletOutputStream.stopBuffering(out); if (byteRange == null) { IOUtils.copy(in, out); } else { IOUtils.copyLarge(in, out, byteRange.getStart(), byteRange.getLength()); } out.flush(); } catch (IOException e) { throw new UncheckedIOException(e); } } protected String fixXPath(String xpath) { // Hack for Flash Url wich doesn't support ':' char return xpath == null ? null : xpath.replace(';', ':'); } @Override public Blob resolveBlob(DocumentModel doc, String xpath) { xpath = fixXPath(xpath); Blob blob; if (xpath.startsWith(BLOBHOLDER_PREFIX)) { BlobHolder bh = doc.getAdapter(BlobHolder.class); if (bh == null) { log.debug("Not a BlobHolder"); return null; } String suffix = xpath.substring(BLOBHOLDER_PREFIX.length()); int index; try { index = Integer.parseInt(suffix); } catch (NumberFormatException e) { log.debug(e.getMessage()); return null; } if (!suffix.equals(Integer.toString(index))) { // attempt to use a non-canonical integer, could be used to bypass // a permission function checking just "blobholder:1" and receiving "blobholder:01" log.debug("Non-canonical index: " + suffix); return null; } if (index == 0) { blob = bh.getBlob(); } else { blob = bh.getBlobs().get(index); } } else { if (!xpath.contains(":")) { // attempt to use a xpath not prefix-qualified, could be used to bypass // a permission function checking just "file:content" and receiving "content" log.debug("Non-canonical xpath: " + xpath); return null; } try { blob = (Blob) doc.getPropertyValue(xpath); } catch (PropertyNotFoundException e) { log.debug(e.getMessage()); return null; } } return blob; } @Override public boolean checkPermission(DocumentModel doc, String xpath, Blob blob, String reason, Map<String, Serializable> extendedInfos) { List<DownloadPermissionDescriptor> descriptors = registry.getDownloadPermissionDescriptors(); if (descriptors.isEmpty()) { return true; } xpath = fixXPath(xpath); Map<String, Object> context = new HashMap<>(); Map<String, Serializable> ei = extendedInfos == null ? Collections.emptyMap() : extendedInfos; NuxeoPrincipal currentUser = ClientLoginModule.getCurrentPrincipal(); context.put("Document", doc); context.put("XPath", xpath); context.put("Blob", blob); context.put("Reason", reason); context.put("Infos", ei); context.put("Rendition", ei.get("rendition")); context.put("CurrentUser", currentUser); for (DownloadPermissionDescriptor descriptor : descriptors) { ScriptEngine engine = scriptEngineManager.getEngineByName(descriptor.getScriptLanguage()); if (engine == null) { throw new NuxeoException("Engine not found for language: " + descriptor.getScriptLanguage() + " in permission: " + descriptor.getName()); } if (!(engine instanceof Invocable)) { throw new NuxeoException("Engine " + engine.getClass().getName() + " not Invocable for language: " + descriptor.getScriptLanguage() + " in permission: " + descriptor.getName()); } Object result; try { engine.eval(descriptor.getScript()); engine.getBindings(ScriptContext.ENGINE_SCOPE).putAll(context); result = ((Invocable) engine).invokeFunction(RUN_FUNCTION); } catch (NoSuchMethodException e) { throw new NuxeoException("Script does not contain function: " + RUN_FUNCTION + "() in permission: " + descriptor.getName(), e); } catch (ScriptException e) { log.error("Failed to evaluate script: " + descriptor.getName(), e); continue; } if (!(result instanceof Boolean)) { log.error("Failed to get boolean result from permission: " + descriptor.getName() + " (" + result + ")"); continue; } boolean allow = ((Boolean) result).booleanValue(); if (!allow) { return false; } } return true; } /** * Internet Explorer file downloads over SSL do not work with certain HTTP cache control headers * <p> * See http://support.microsoft.com/kb/323308/ * <p> * What is not mentioned in the above Knowledge Base is that "Pragma: no-cache" also breaks download in MSIE over * SSL */ protected void addCacheControlHeaders(HttpServletRequest request, HttpServletResponse response) { String userAgent = request.getHeader("User-Agent"); boolean secure = request.isSecure(); if (!secure) { String nvh = request.getHeader(NUXEO_VIRTUAL_HOST); if (nvh == null) { nvh = Framework.getProperty(VH_PARAM); } if (nvh != null) { secure = nvh.startsWith("https"); } } String cacheControl; if (userAgent != null && userAgent.contains("MSIE") && (secure || forceNoCacheOnMSIE())) { cacheControl = "max-age=15, must-revalidate"; } else { cacheControl = "private, must-revalidate"; response.setHeader("Pragma", "no-cache"); response.setDateHeader("Expires", 0); } log.debug("Setting Cache-Control: " + cacheControl); response.setHeader("Cache-Control", cacheControl); } protected static boolean forceNoCacheOnMSIE() { // see NXP-7759 return Framework.isBooleanPropertyTrue(FORCE_NO_CACHE_ON_MSIE); } @Override public void logDownload(DocumentModel doc, String xpath, String filename, String reason, Map<String, Serializable> extendedInfos) { EventService eventService = Framework.getService(EventService.class); if (eventService == null) { return; } EventContext ctx; if (doc != null) { @SuppressWarnings("resource") CoreSession session = doc.getCoreSession(); Principal principal = session == null ? getPrincipal() : session.getPrincipal(); ctx = new DocumentEventContext(session, principal, doc); ctx.setProperty(CoreEventConstants.REPOSITORY_NAME, doc.getRepositoryName()); ctx.setProperty(CoreEventConstants.SESSION_ID, doc.getSessionId()); } else { ctx = new EventContextImpl(null, getPrincipal()); } Map<String, Serializable> map = new HashMap<>(); map.put("blobXPath", xpath); map.put("blobFilename", filename); map.put("downloadReason", reason); if (extendedInfos != null) { map.putAll(extendedInfos); } ctx.setProperty("extendedInfos", (Serializable) map); ctx.setProperty("comment", filename); Event event = ctx.newEvent(EVENT_NAME); eventService.fireEvent(event); } protected static NuxeoPrincipal getPrincipal() { NuxeoPrincipal principal = ClientLoginModule.getCurrentPrincipal(); if (principal == null) { if (!Framework.isTestModeSet()) { throw new NuxeoException("Missing security context, login() not done"); } principal = new SystemPrincipal(null); } return principal; } }