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