001/*
002 * (C) Copyright 2015-2018 Nuxeo (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 *     Estelle Giuly <egiuly@nuxeo.com>
019 */
020package org.nuxeo.ecm.core.io.download;
021
022import java.io.IOException;
023import java.io.InputStream;
024import java.io.OutputStream;
025import java.io.Serializable;
026import java.io.UncheckedIOException;
027import java.net.URI;
028import java.net.URLEncoder;
029import java.security.Principal;
030import java.util.ArrayList;
031import java.util.Collections;
032import java.util.HashMap;
033import java.util.List;
034import java.util.Map;
035import java.util.Objects;
036import java.util.UUID;
037import java.util.function.Consumer;
038import java.util.function.Supplier;
039import java.util.regex.Pattern;
040
041import javax.script.Invocable;
042import javax.script.ScriptContext;
043import javax.script.ScriptEngine;
044import javax.script.ScriptEngineManager;
045import javax.script.ScriptException;
046import javax.servlet.http.HttpServletRequest;
047import javax.servlet.http.HttpServletResponse;
048
049import org.apache.commons.codec.digest.DigestUtils;
050import org.apache.commons.io.IOUtils;
051import org.apache.commons.lang3.StringUtils;
052import org.apache.commons.lang3.tuple.Pair;
053import org.apache.commons.logging.Log;
054import org.apache.commons.logging.LogFactory;
055import org.nuxeo.common.utils.URIUtils;
056import org.nuxeo.ecm.core.api.Blob;
057import org.nuxeo.ecm.core.api.CloseableCoreSession;
058import org.nuxeo.ecm.core.api.CoreInstance;
059import org.nuxeo.ecm.core.api.CoreSession;
060import org.nuxeo.ecm.core.api.DocumentModel;
061import org.nuxeo.ecm.core.api.DocumentRef;
062import org.nuxeo.ecm.core.api.DocumentSecurityException;
063import org.nuxeo.ecm.core.api.IdRef;
064import org.nuxeo.ecm.core.api.NuxeoException;
065import org.nuxeo.ecm.core.api.NuxeoPrincipal;
066import org.nuxeo.ecm.core.api.blobholder.BlobHolder;
067import org.nuxeo.ecm.core.api.blobholder.BlobHolderAdapterService;
068import org.nuxeo.ecm.core.api.event.CoreEventConstants;
069import org.nuxeo.ecm.core.api.impl.blob.AsyncBlob;
070import org.nuxeo.ecm.core.api.local.ClientLoginModule;
071import org.nuxeo.ecm.core.api.model.PropertyNotFoundException;
072import org.nuxeo.ecm.core.blob.BlobManager.UsageHint;
073import org.nuxeo.ecm.core.event.Event;
074import org.nuxeo.ecm.core.event.EventContext;
075import org.nuxeo.ecm.core.event.EventService;
076import org.nuxeo.ecm.core.event.impl.DocumentEventContext;
077import org.nuxeo.ecm.core.event.impl.EventContextImpl;
078import org.nuxeo.ecm.core.transientstore.api.TransientStore;
079import org.nuxeo.ecm.core.transientstore.api.TransientStoreService;
080import org.nuxeo.runtime.api.Framework;
081import org.nuxeo.runtime.model.ComponentInstance;
082import org.nuxeo.runtime.model.DefaultComponent;
083import org.nuxeo.runtime.model.SimpleContributionRegistry;
084import org.nuxeo.runtime.transaction.TransactionHelper;
085
086/**
087 * This service allows the download of blobs to a HTTP response.
088 *
089 * @since 7.3
090 */
091public class DownloadServiceImpl extends DefaultComponent implements DownloadService {
092
093    private static final Log log = LogFactory.getLog(DownloadServiceImpl.class);
094
095    protected static final int DOWNLOAD_BUFFER_SIZE = 1024 * 512;
096
097    private static final String NUXEO_VIRTUAL_HOST = "nuxeo-virtual-host";
098
099    private static final String VH_PARAM = "nuxeo.virtual.host";
100
101    private static final String FORCE_NO_CACHE_ON_MSIE = "org.nuxeo.download.force.nocache.msie";
102
103    private static final String XP = "permissions";
104
105    private static final String REDIRECT_RESOLVER = "redirectResolver";
106
107    private static final String RUN_FUNCTION = "run";
108
109    private static final Pattern FILENAME_SANITIZATION_REGEX = Pattern.compile(";\\w+=.*");
110
111    protected enum Action {
112        DOWNLOAD, DOWNLOAD_FROM_DOC, INFO, BLOBSTATUS
113    };
114
115    private DownloadPermissionRegistry registry = new DownloadPermissionRegistry();
116
117    private ScriptEngineManager scriptEngineManager;
118
119    protected RedirectResolver redirectResolver = new DefaultRedirectResolver();
120
121    protected List<RedirectResolverDescriptor> redirectResolverContributions = new ArrayList<>();
122
123    public static class DownloadPermissionRegistry extends SimpleContributionRegistry<DownloadPermissionDescriptor> {
124
125        @Override
126        public String getContributionId(DownloadPermissionDescriptor contrib) {
127            return contrib.getName();
128        }
129
130        @Override
131        public boolean isSupportingMerge() {
132            return true;
133        }
134
135        @Override
136        public DownloadPermissionDescriptor clone(DownloadPermissionDescriptor orig) {
137            return new DownloadPermissionDescriptor(orig);
138        }
139
140        @Override
141        public void merge(DownloadPermissionDescriptor src, DownloadPermissionDescriptor dst) {
142            dst.merge(src);
143        }
144
145        public DownloadPermissionDescriptor getDownloadPermissionDescriptor(String id) {
146            return getCurrentContribution(id);
147        }
148
149        /** Returns descriptors sorted by name. */
150        public List<DownloadPermissionDescriptor> getDownloadPermissionDescriptors() {
151            List<DownloadPermissionDescriptor> descriptors = new ArrayList<>(currentContribs.values());
152            Collections.sort(descriptors);
153            return descriptors;
154        }
155    }
156
157    public DownloadServiceImpl() {
158        scriptEngineManager = new ScriptEngineManager();
159    }
160
161    @Override
162    public void registerContribution(Object contribution, String extensionPoint, ComponentInstance contributor) {
163        if (XP.equals(extensionPoint)) {
164            DownloadPermissionDescriptor descriptor = (DownloadPermissionDescriptor) contribution;
165            registry.addContribution(descriptor);
166        } else if (REDIRECT_RESOLVER.equals(extensionPoint)) {
167            redirectResolver = ((RedirectResolverDescriptor) contribution).getObject();
168            // Save contribution
169            redirectResolverContributions.add((RedirectResolverDescriptor) contribution);
170        } else {
171            throw new UnsupportedOperationException(extensionPoint);
172        }
173    }
174
175    @Override
176    public void unregisterContribution(Object contribution, String extensionPoint, ComponentInstance contributor) {
177        if (XP.equals(extensionPoint)) {
178            DownloadPermissionDescriptor descriptor = (DownloadPermissionDescriptor) contribution;
179            registry.removeContribution(descriptor);
180        } else if (REDIRECT_RESOLVER.equals(extensionPoint)) {
181            redirectResolverContributions.remove(contribution);
182            if (redirectResolverContributions.size() == 0) {
183                // If no more custom contribution go back to the default one
184                redirectResolver = new DefaultRedirectResolver();
185            } else {
186                // Go back to the last contribution added
187                redirectResolver = redirectResolverContributions.get(redirectResolverContributions.size() - 1)
188                                                                .getObject();
189            }
190        } else {
191            throw new UnsupportedOperationException(extensionPoint);
192        }
193    }
194
195    /**
196     * {@inheritDoc} Multipart download are not yet supported. You can only provide a blob singleton at this time.
197     */
198    @Override
199    public String storeBlobs(List<Blob> blobs) {
200        if (blobs.size() > 1) {
201            throw new IllegalArgumentException("multipart download not yet implemented");
202        }
203        TransientStore ts = Framework.getService(TransientStoreService.class).getStore(TRANSIENT_STORE_STORE_NAME);
204        String storeKey = UUID.randomUUID().toString();
205        ts.putBlobs(storeKey, blobs);
206        ts.setCompleted(storeKey, true);
207        return storeKey;
208    }
209
210    @Override
211    public String getDownloadUrl(DocumentModel doc, String xpath, String filename) {
212        return getDownloadUrl(doc.getRepositoryName(), doc.getId(), xpath, filename);
213    }
214
215    @Override
216    public String getDownloadUrl(String repositoryName, String docId, String xpath, String filename) {
217        StringBuilder sb = new StringBuilder();
218        sb.append(NXFILE);
219        sb.append("/").append(repositoryName);
220        sb.append("/").append(docId);
221        if (xpath != null) {
222            sb.append("/").append(xpath);
223            if (filename != null) {
224                // make sure filename doesn't contain path separators
225                filename = getSanitizedFilenameWithoutPath(filename);
226                sb.append("/").append(URIUtils.quoteURIPathComponent(filename, true));
227            }
228        }
229        return sb.toString();
230    }
231
232    protected String getSanitizedFilenameWithoutPath(String filename) {
233        int sep = Math.max(filename.lastIndexOf('\\'), filename.lastIndexOf('/'));
234        if (sep != -1) {
235            filename = filename.substring(sep + 1);
236        }
237
238        return FILENAME_SANITIZATION_REGEX.matcher(filename).replaceAll("");
239    }
240
241    @Override
242    public String getDownloadUrl(String storeKey) {
243        return NXBIGBLOB + "/" + storeKey;
244    }
245
246    /**
247     * Gets the download path and action of the URL to use to download blobs. For instance, from the path
248     * "nxfile/default/3727ef6b-cf8c-4f27-ab2c-79de0171a2c8/files:files/0/file/image.png", the pair
249     * ("default/3727ef6b-cf8c-4f27-ab2c-79de0171a2c8/files:files/0/file/image.png", Action.DOWNLOAD_FROM_DOC) is
250     * returned.
251     *
252     * @param path the path of the URL to use to download blobs
253     * @return the pair download path and action
254     * @since 9.1
255     */
256    protected Pair<String, Action> getDownloadPathAndAction(String path) {
257        if (path.startsWith("/")) {
258            path = path.substring(1);
259        }
260        int slash = path.indexOf('/');
261        if (slash < 0) {
262            return null;
263        }
264        String type = path.substring(0, slash);
265        String downloadPath = path.substring(slash + 1);
266        switch (type) {
267        case NXDOWNLOADINFO:
268            // used by nxdropout.js
269            return Pair.of(downloadPath, Action.INFO);
270        case NXFILE:
271        case NXBIGFILE:
272            return Pair.of(downloadPath, Action.DOWNLOAD_FROM_DOC);
273        case NXBIGZIPFILE:
274        case NXBIGBLOB:
275            return Pair.of(downloadPath, Action.DOWNLOAD);
276        case NXBLOBSTATUS:
277            return Pair.of(downloadPath, Action.BLOBSTATUS);
278        default:
279            return null;
280        }
281    }
282
283    @Override
284    public Blob resolveBlobFromDownloadUrl(String url) {
285        String nuxeoUrl = Framework.getProperty("nuxeo.url");
286        if (!url.startsWith(nuxeoUrl)) {
287            return null;
288        }
289        String path = url.substring(nuxeoUrl.length() + 1);
290        Pair<String, Action> pair = getDownloadPathAndAction(path);
291        if (pair == null) {
292            return null;
293        }
294        String downloadPath = pair.getLeft();
295        try {
296            DownloadBlobInfo downloadBlobInfo = new DownloadBlobInfo(downloadPath);
297            try (CloseableCoreSession session = CoreInstance.openCoreSession(downloadBlobInfo.repository)) {
298                DocumentRef docRef = new IdRef(downloadBlobInfo.docId);
299                if (!session.exists(docRef)) {
300                    return null;
301                }
302                DocumentModel doc = session.getDocument(docRef);
303                return resolveBlob(doc, downloadBlobInfo.xpath);
304            }
305        } catch (IllegalArgumentException e) {
306            return null;
307        }
308    }
309
310    @Override
311    public void handleDownload(HttpServletRequest req, HttpServletResponse resp, String baseUrl, String path)
312            throws IOException {
313        Pair<String, Action> pair = getDownloadPathAndAction(path);
314        if (pair == null) {
315            resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Invalid URL syntax");
316            return;
317        }
318        String downloadPath = pair.getLeft();
319        Action action = pair.getRight();
320        switch (action) {
321        case INFO:
322            handleDownload(req, resp, downloadPath, baseUrl, true);
323            break;
324        case DOWNLOAD_FROM_DOC:
325            handleDownload(req, resp, downloadPath, baseUrl, false);
326            break;
327        case DOWNLOAD:
328            downloadBlob(req, resp, downloadPath, "download");
329            break;
330        case BLOBSTATUS:
331            downloadBlobStatus(req, resp, downloadPath, "download");
332            break;
333        default:
334            resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Invalid URL syntax");
335        }
336    }
337
338    protected void handleDownload(HttpServletRequest req, HttpServletResponse resp, String downloadPath, String baseUrl,
339            boolean info) throws IOException {
340        boolean tx = false;
341        DownloadBlobInfo downloadBlobInfo;
342        try {
343            downloadBlobInfo = new DownloadBlobInfo(downloadPath);
344        } catch (IllegalArgumentException e) {
345            resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Invalid URL syntax");
346            return;
347        }
348
349        try {
350            if (!TransactionHelper.isTransactionActive()) {
351                // Manually start and stop a transaction around repository access to be able to release transactional
352                // resources without waiting for the download that can take a long time (longer than the transaction
353                // timeout) especially if the client or the connection is slow.
354                tx = TransactionHelper.startTransaction();
355            }
356            String xpath = downloadBlobInfo.xpath;
357            String filename = downloadBlobInfo.filename;
358            try (CloseableCoreSession session = CoreInstance.openCoreSession(downloadBlobInfo.repository)) {
359                DocumentRef docRef = new IdRef(downloadBlobInfo.docId);
360                if (!session.exists(docRef)) {
361                    // Send a security exception to force authentication, if the current user is anonymous
362                    Principal principal = req.getUserPrincipal();
363                    if (principal instanceof NuxeoPrincipal) {
364                        NuxeoPrincipal nuxeoPrincipal = (NuxeoPrincipal) principal;
365                        if (nuxeoPrincipal.isAnonymous()) {
366                            throw new DocumentSecurityException("Authentication is needed for downloading the blob");
367                        }
368                    }
369                    resp.sendError(HttpServletResponse.SC_NOT_FOUND, "No document found");
370                    return;
371                }
372                DocumentModel doc = session.getDocument(docRef);
373                if (info) {
374                    Blob blob = resolveBlob(doc, xpath);
375                    if (blob == null) {
376                        resp.sendError(HttpServletResponse.SC_NOT_FOUND, "No blob found");
377                        return;
378                    }
379                    String downloadUrl = baseUrl + getDownloadUrl(doc, xpath, filename);
380                    String result = blob.getMimeType() + ':' + URLEncoder.encode(blob.getFilename(), "UTF-8") + ':'
381                            + downloadUrl;
382                    resp.setContentType("text/plain");
383                    resp.getWriter().write(result);
384                    resp.getWriter().flush();
385                } else {
386                    downloadBlob(req, resp, doc, xpath, null, filename, "download");
387                }
388            }
389        } catch (NuxeoException e) {
390            if (tx) {
391                TransactionHelper.setTransactionRollbackOnly();
392            }
393            throw new IOException(e);
394        } finally {
395            if (tx) {
396                TransactionHelper.commitOrRollbackTransaction();
397            }
398        }
399    }
400
401    @Override
402    public void downloadBlobStatus(HttpServletRequest request, HttpServletResponse response, String key, String reason)
403            throws IOException {
404        this.downloadBlob(request, response, key, reason, true);
405    }
406
407    @Override
408    public void downloadBlob(HttpServletRequest request, HttpServletResponse response, String key, String reason)
409            throws IOException {
410        this.downloadBlob(request, response, key, reason, false);
411    }
412
413    protected void downloadBlob(HttpServletRequest request, HttpServletResponse response, String key, String reason,
414            boolean status) throws IOException {
415        TransientStore ts = Framework.getService(TransientStoreService.class).getStore(TRANSIENT_STORE_STORE_NAME);
416        if (!ts.exists(key)) {
417            response.sendError(HttpServletResponse.SC_NOT_FOUND);
418            return;
419        }
420        List<Blob> blobs = ts.getBlobs(key);
421        if (blobs == null || blobs.isEmpty()) {
422            response.sendError(HttpServletResponse.SC_NOT_FOUND);
423            return;
424        }
425        if (blobs.size() > 1) {
426            throw new IllegalArgumentException("multipart download not yet implemented");
427        }
428        if (ts.getParameter(key, TRANSIENT_STORE_PARAM_ERROR) != null) {
429            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
430                    (String) ts.getParameter(key, TRANSIENT_STORE_PARAM_ERROR));
431        } else {
432            boolean isCompleted = ts.isCompleted(key);
433            if (!status && !isCompleted) {
434                response.setStatus(HttpServletResponse.SC_ACCEPTED);
435                return;
436            }
437            Blob blob;
438            if (status) {
439                Serializable progress = ts.getParameter(key, TRANSIENT_STORE_PARAM_PROGRESS);
440                blob = new AsyncBlob(key, isCompleted, progress != null ? (int) progress : -1);
441            } else {
442                blob = blobs.get(0);
443            }
444            try {
445                downloadBlob(request, response, null, null, blob, blob.getFilename(), reason);
446            } finally {
447                if (!status) {
448                    ts.remove(key);
449                }
450            }
451        }
452    }
453
454    @Override
455    public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath,
456            Blob blob, String filename, String reason) throws IOException {
457        downloadBlob(request, response, doc, xpath, blob, filename, reason, Collections.emptyMap());
458    }
459
460    @Override
461    public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath,
462            Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos) throws IOException {
463        downloadBlob(request, response, doc, xpath, blob, filename, reason, extendedInfos, null);
464    }
465
466    @Override
467    public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath,
468            Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos, Boolean inline)
469                    throws IOException {
470        if (blob == null) {
471            if (doc == null) {
472                throw new NuxeoException("No doc specified");
473            }
474            blob = resolveBlob(doc, xpath);
475            if (blob == null) {
476                response.sendError(HttpServletResponse.SC_NOT_FOUND, "No blob found");
477                return;
478            }
479        }
480        final Blob fblob = blob;
481        downloadBlob(request, response, doc, xpath, blob, filename, reason, extendedInfos, inline,
482                byteRange -> transferBlobWithByteRange(fblob, byteRange, response));
483    }
484
485    @Override
486    public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath,
487            Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos, Boolean inline,
488            Consumer<ByteRange> blobTransferer) throws IOException {
489        Objects.requireNonNull(blob);
490        // check blob permissions
491        if (!checkPermission(doc, xpath, blob, reason, extendedInfos)) {
492            response.sendError(HttpServletResponse.SC_FORBIDDEN, "Permission denied");
493            return;
494        }
495
496        // check Blob Manager external download link
497        URI uri = redirectResolver.getURI(blob, UsageHint.DOWNLOAD, request);
498        if (uri != null) {
499            try {
500                Map<String, Serializable> ei = new HashMap<>();
501                if (extendedInfos != null) {
502                    ei.putAll(extendedInfos);
503                }
504                ei.put("redirect", uri.toString());
505                logDownload(doc, xpath, filename, reason, ei);
506                response.sendRedirect(uri.toString());
507            } catch (IOException ioe) {
508                DownloadHelper.handleClientDisconnect(ioe);
509            }
510            return;
511        }
512
513        try {
514            String digest = blob.getDigest();
515            if (digest == null) {
516                digest = DigestUtils.md5Hex(blob.getStream());
517            }
518            String etag = '"' + digest + '"'; // with quotes per RFC7232 2.3
519            response.setHeader("ETag", etag); // re-send even on SC_NOT_MODIFIED
520            addCacheControlHeaders(request, response);
521
522            String ifNoneMatch = request.getHeader("If-None-Match");
523            if (ifNoneMatch != null) {
524                boolean match = false;
525                if (ifNoneMatch.equals("*")) {
526                    match = true;
527                } else {
528                    for (String previousEtag : StringUtils.split(ifNoneMatch, ", ")) {
529                        if (previousEtag.equals(etag)) {
530                            match = true;
531                            break;
532                        }
533                    }
534                }
535                if (match) {
536                    String method = request.getMethod();
537                    if (method.equals("GET") || method.equals("HEAD")) {
538                        response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
539                    } else {
540                        // per RFC7232 3.2
541                        response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
542                    }
543                    return;
544                }
545            }
546
547            // regular processing
548
549            if (StringUtils.isBlank(filename)) {
550                filename = StringUtils.defaultIfBlank(blob.getFilename(), "file");
551            }
552            String contentDisposition = DownloadHelper.getRFC2231ContentDisposition(request, filename, inline);
553            response.setHeader("Content-Disposition", contentDisposition);
554            response.setContentType(blob.getMimeType());
555            if (StringUtils.isNotBlank(blob.getEncoding())) {
556                try {
557                    response.setCharacterEncoding(blob.getEncoding());
558                } catch (IllegalArgumentException e) {
559                    // ignore invalid encoding
560                }
561            }
562
563            long length = blob.getLength();
564            response.setHeader("Accept-Ranges", "bytes");
565            String range = request.getHeader("Range");
566            ByteRange byteRange;
567            if (StringUtils.isBlank(range)) {
568                byteRange = null;
569            } else {
570                byteRange = DownloadHelper.parseRange(range, length);
571                if (byteRange == null) {
572                    log.error("Invalid byte range received: " + range);
573                } else {
574                    response.setHeader("Content-Range",
575                            "bytes " + byteRange.getStart() + "-" + byteRange.getEnd() + "/" + length);
576                    response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
577                }
578            }
579            long contentLength = byteRange == null ? length : byteRange.getLength();
580            response.setContentLengthLong(contentLength);
581
582            logDownload(doc, xpath, filename, reason, extendedInfos);
583
584            // execute the final download
585            blobTransferer.accept(byteRange);
586        } catch (UncheckedIOException e) {
587            DownloadHelper.handleClientDisconnect(e.getCause());
588        } catch (IOException ioe) {
589            DownloadHelper.handleClientDisconnect(ioe);
590        }
591    }
592
593    protected void transferBlobWithByteRange(Blob blob, ByteRange byteRange, HttpServletResponse response)
594            throws UncheckedIOException {
595        transferBlobWithByteRange(blob, byteRange, () -> {
596            try {
597                return response.getOutputStream();
598            } catch (IOException e) {
599                throw new UncheckedIOException(e);
600            }
601        });
602        try {
603            response.flushBuffer();
604        } catch (IOException e) {
605            throw new UncheckedIOException(e);
606        }
607    }
608
609    @Override
610    public void transferBlobWithByteRange(Blob blob, ByteRange byteRange, Supplier<OutputStream> outputStreamSupplier)
611            throws UncheckedIOException {
612        try (InputStream in = blob.getStream()) {
613            @SuppressWarnings("resource")
614            OutputStream out = outputStreamSupplier.get(); // not ours to close
615            BufferingServletOutputStream.stopBuffering(out);
616            if (byteRange == null) {
617                IOUtils.copy(in, out);
618            } else {
619                IOUtils.copyLarge(in, out, byteRange.getStart(), byteRange.getLength());
620            }
621            out.flush();
622        } catch (IOException e) {
623            throw new UncheckedIOException(e);
624        }
625    }
626
627    protected String fixXPath(String xpath) {
628        // Hack for Flash Url wich doesn't support ':' char
629        return xpath == null ? null : xpath.replace(';', ':');
630    }
631
632    @Override
633    public Blob resolveBlob(DocumentModel doc) {
634        BlobHolderAdapterService blobHolderAdapterService = Framework.getService(BlobHolderAdapterService.class);
635        return blobHolderAdapterService.getBlobHolderAdapter(doc, "download").getBlob();
636    }
637
638    @Override
639    public Blob resolveBlob(DocumentModel doc, String xpath) {
640        if (xpath == null) {
641            return resolveBlob(doc);
642        }
643        xpath = fixXPath(xpath);
644        Blob blob;
645        if (xpath.startsWith(BLOBHOLDER_PREFIX)) {
646            BlobHolder bh = doc.getAdapter(BlobHolder.class);
647            if (bh == null) {
648                log.debug("Not a BlobHolder");
649                return null;
650            }
651            String suffix = xpath.substring(BLOBHOLDER_PREFIX.length());
652            int index;
653            try {
654                index = Integer.parseInt(suffix);
655            } catch (NumberFormatException e) {
656                log.debug(e.getMessage());
657                return null;
658            }
659            if (!suffix.equals(Integer.toString(index))) {
660                // attempt to use a non-canonical integer, could be used to bypass
661                // a permission function checking just "blobholder:1" and receiving "blobholder:01"
662                log.debug("Non-canonical index: " + suffix);
663                return null;
664            }
665            if (index == 0) {
666                blob = bh.getBlob();
667            } else {
668                blob = bh.getBlobs().get(index);
669            }
670        } else {
671            if (!xpath.contains(":")) {
672                // attempt to use a xpath not prefix-qualified, could be used to bypass
673                // a permission function checking just "file:content" and receiving "content"
674                log.debug("Non-canonical xpath: " + xpath);
675                return null;
676            }
677            try {
678                blob = (Blob) doc.getPropertyValue(xpath);
679            } catch (PropertyNotFoundException e) {
680                log.debug(e.getMessage());
681                return null;
682            }
683        }
684        return blob;
685    }
686
687    @Override
688    public boolean checkPermission(DocumentModel doc, String xpath, Blob blob, String reason,
689            Map<String, Serializable> extendedInfos) {
690        List<DownloadPermissionDescriptor> descriptors = registry.getDownloadPermissionDescriptors();
691        if (descriptors.isEmpty()) {
692            return true;
693        }
694        xpath = fixXPath(xpath);
695        Map<String, Object> context = new HashMap<>();
696        Map<String, Serializable> ei = extendedInfos == null ? Collections.emptyMap() : extendedInfos;
697        NuxeoPrincipal currentUser = ClientLoginModule.getCurrentPrincipal();
698        context.put("Document", doc);
699        context.put("XPath", xpath);
700        context.put("Blob", blob);
701        context.put("Reason", reason);
702        context.put("Infos", ei);
703        context.put("Rendition", ei.get("rendition"));
704        context.put("CurrentUser", currentUser);
705        for (DownloadPermissionDescriptor descriptor : descriptors) {
706            ScriptEngine engine = scriptEngineManager.getEngineByName(descriptor.getScriptLanguage());
707            if (engine == null) {
708                throw new NuxeoException("Engine not found for language: " + descriptor.getScriptLanguage()
709                        + " in permission: " + descriptor.getName());
710            }
711            if (!(engine instanceof Invocable)) {
712                throw new NuxeoException("Engine " + engine.getClass().getName() + " not Invocable for language: "
713                        + descriptor.getScriptLanguage() + " in permission: " + descriptor.getName());
714            }
715            Object result;
716            try {
717                engine.eval(descriptor.getScript());
718                engine.getBindings(ScriptContext.ENGINE_SCOPE).putAll(context);
719                result = ((Invocable) engine).invokeFunction(RUN_FUNCTION);
720            } catch (NoSuchMethodException e) {
721                throw new NuxeoException("Script does not contain function: " + RUN_FUNCTION + "() in permission: "
722                        + descriptor.getName(), e);
723            } catch (ScriptException e) {
724                log.error("Failed to evaluate script: " + descriptor.getName(), e);
725                continue;
726            }
727            if (!(result instanceof Boolean)) {
728                log.error(
729                        "Failed to get boolean result from permission: " + descriptor.getName() + " (" + result + ")");
730                continue;
731            }
732            boolean allow = ((Boolean) result).booleanValue();
733            if (!allow) {
734                return false;
735            }
736        }
737        return true;
738    }
739
740    /**
741     * Internet Explorer file downloads over SSL do not work with certain HTTP cache control headers
742     * <p>
743     * See http://support.microsoft.com/kb/323308/
744     * <p>
745     * What is not mentioned in the above Knowledge Base is that "Pragma: no-cache" also breaks download in MSIE over
746     * SSL
747     */
748    protected void addCacheControlHeaders(HttpServletRequest request, HttpServletResponse response) {
749        String userAgent = request.getHeader("User-Agent");
750        boolean secure = request.isSecure();
751        if (!secure) {
752            String nvh = request.getHeader(NUXEO_VIRTUAL_HOST);
753            if (nvh == null) {
754                nvh = Framework.getProperty(VH_PARAM);
755            }
756            if (nvh != null) {
757                secure = nvh.startsWith("https");
758            }
759        }
760        if (userAgent != null && userAgent.contains("MSIE") && (secure || forceNoCacheOnMSIE())) {
761            String cacheControl = "max-age=15, must-revalidate";
762            log.debug("Setting Cache-Control: " + cacheControl);
763            response.setHeader("Cache-Control", cacheControl);
764        }
765    }
766
767    protected static boolean forceNoCacheOnMSIE() {
768        // see NXP-7759
769        return Framework.isBooleanPropertyTrue(FORCE_NO_CACHE_ON_MSIE);
770    }
771
772    @Override
773    public void logDownload(DocumentModel doc, String xpath, String filename, String reason,
774            Map<String, Serializable> extendedInfos) {
775        if ("webengine".equals(reason)) {
776            // don't log JSON operation results as downloads
777            return;
778        }
779        EventService eventService = Framework.getService(EventService.class);
780        if (eventService == null) {
781            return;
782        }
783        EventContext ctx;
784        if (doc != null) {
785            CoreSession session = doc.getCoreSession();
786            Principal principal = session == null ? getPrincipal() : session.getPrincipal();
787            ctx = new DocumentEventContext(session, principal, doc);
788            ctx.setProperty(CoreEventConstants.REPOSITORY_NAME, doc.getRepositoryName());
789            ctx.setProperty(CoreEventConstants.SESSION_ID, doc.getSessionId());
790        } else {
791            ctx = new EventContextImpl(null, getPrincipal());
792        }
793        Map<String, Serializable> map = new HashMap<>();
794        map.put("blobXPath", xpath);
795        map.put("blobFilename", filename);
796        map.put("downloadReason", reason);
797        if (extendedInfos != null) {
798            map.putAll(extendedInfos);
799        }
800        ctx.setProperty("extendedInfos", (Serializable) map);
801        ctx.setProperty("comment", filename);
802        Event event = ctx.newEvent(EVENT_NAME);
803        eventService.fireEvent(event);
804    }
805
806    protected static NuxeoPrincipal getPrincipal() {
807        return ClientLoginModule.getCurrentPrincipal();
808    }
809
810}