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