org.nuxeo.ecm.core.io.download.DownloadServiceImpl.java Source code

Java tutorial

Introduction

Here is the source code for org.nuxeo.ecm.core.io.download.DownloadServiceImpl.java

Source

/*
 * (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;
    }

}