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