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.File;
023import java.io.IOException;
024import java.io.InputStream;
025import java.io.OutputStream;
026import java.io.Serializable;
027import java.io.UncheckedIOException;
028import java.io.UnsupportedEncodingException;
029import java.net.URI;
030import java.net.URLEncoder;
031import java.util.Calendar;
032import java.util.Collections;
033import java.util.Enumeration;
034import java.util.HashMap;
035import java.util.HashSet;
036import java.util.List;
037import java.util.Map;
038import java.util.Set;
039import java.util.UUID;
040import java.util.function.Consumer;
041import java.util.function.Supplier;
042import java.util.regex.Pattern;
043
044import javax.script.Invocable;
045import javax.script.ScriptContext;
046import javax.script.ScriptEngine;
047import javax.script.ScriptEngineManager;
048import javax.script.ScriptException;
049import javax.servlet.http.HttpServletRequest;
050import javax.servlet.http.HttpServletResponse;
051
052import org.apache.commons.codec.DecoderException;
053import org.apache.commons.codec.binary.Base64;
054import org.apache.commons.codec.binary.Hex;
055import org.apache.commons.codec.digest.DigestUtils;
056import org.apache.commons.io.IOUtils;
057import org.apache.commons.lang3.StringUtils;
058import org.apache.commons.lang3.tuple.Pair;
059import org.apache.logging.log4j.LogManager;
060import org.apache.logging.log4j.Logger;
061import org.nuxeo.common.utils.URIUtils;
062import org.nuxeo.ecm.core.api.Blob;
063import org.nuxeo.ecm.core.api.CoreInstance;
064import org.nuxeo.ecm.core.api.CoreSession;
065import org.nuxeo.ecm.core.api.DocumentModel;
066import org.nuxeo.ecm.core.api.DocumentRef;
067import org.nuxeo.ecm.core.api.DocumentSecurityException;
068import org.nuxeo.ecm.core.api.IdRef;
069import org.nuxeo.ecm.core.api.NuxeoException;
070import org.nuxeo.ecm.core.api.NuxeoPrincipal;
071import org.nuxeo.ecm.core.api.blobholder.BlobHolder;
072import org.nuxeo.ecm.core.api.blobholder.BlobHolderAdapterService;
073import org.nuxeo.ecm.core.api.event.CoreEventConstants;
074import org.nuxeo.ecm.core.api.impl.blob.AsyncBlob;
075import org.nuxeo.ecm.core.api.model.PropertyNotFoundException;
076import org.nuxeo.ecm.core.blob.BlobManager;
077import org.nuxeo.ecm.core.blob.BlobManager.UsageHint;
078import org.nuxeo.ecm.core.blob.BlobProvider;
079import org.nuxeo.ecm.core.blob.ByteRange;
080import org.nuxeo.ecm.core.blob.LocalBlobProvider;
081import org.nuxeo.ecm.core.blob.ManagedBlob;
082import org.nuxeo.ecm.core.blob.binary.DefaultBinaryManager;
083import org.nuxeo.ecm.core.event.Event;
084import org.nuxeo.ecm.core.event.EventContext;
085import org.nuxeo.ecm.core.event.EventService;
086import org.nuxeo.ecm.core.event.impl.DocumentEventContext;
087import org.nuxeo.ecm.core.event.impl.EventContextImpl;
088import org.nuxeo.ecm.core.io.NginxConstants;
089import org.nuxeo.ecm.core.transientstore.api.TransientStore;
090import org.nuxeo.ecm.core.transientstore.api.TransientStoreService;
091import org.nuxeo.runtime.api.Framework;
092import org.nuxeo.runtime.model.ComponentContext;
093import org.nuxeo.runtime.model.DefaultComponent;
094import org.nuxeo.runtime.services.config.ConfigurationService;
095import org.nuxeo.runtime.transaction.TransactionHelper;
096
097/**
098 * This service allows the download of blobs to a HTTP response.
099 *
100 * @since 7.3
101 */
102public class DownloadServiceImpl extends DefaultComponent implements DownloadService {
103
104    private static final Logger log = LogManager.getLogger(DownloadServiceImpl.class);
105
106    public static final String XP_PERMISSIONS = "permissions";
107
108    public static final String XP_REDIRECT_RESOLVER = "redirectResolver";
109
110    protected static final int DOWNLOAD_BUFFER_SIZE = 1024 * 512;
111
112    private static final String NUXEO_VIRTUAL_HOST = "nuxeo-virtual-host";
113
114    private static final String VH_PARAM = "nuxeo.virtual.host";
115
116    private static final String FORCE_NO_CACHE_ON_MSIE = "org.nuxeo.download.force.nocache.msie";
117
118    /** @since 11.1 */
119    public static final String DOWNLOAD_URL_FOLLOW_REDIRECT = "org.nuxeo.download.url.follow.redirect";
120
121    private static final String RUN_FUNCTION = "run";
122
123    private static final Pattern FILENAME_SANITIZATION_REGEX = Pattern.compile(";\\w+=.*");
124
125    private static final String DC_MODIFIED = "dc:modified";
126
127    private static final String MD5 = "MD5";
128
129    protected enum Action {
130        DOWNLOAD, DOWNLOAD_FROM_DOC, INFO, BLOBSTATUS
131    }
132
133    protected ScriptEngineManager scriptEngineManager = new ScriptEngineManager();
134
135    protected RedirectResolver redirectResolver;
136
137    @Override
138    public void start(ComponentContext context) {
139        super.start(context);
140        List<RedirectResolverDescriptor> descriptors = getDescriptors(XP_REDIRECT_RESOLVER);
141        if (!descriptors.isEmpty()) {
142            RedirectResolverDescriptor descriptor = descriptors.get(descriptors.size() - 1);
143            try {
144                redirectResolver = descriptor.klass.getDeclaredConstructor().newInstance();
145            } catch (ReflectiveOperationException e) {
146                log.error("Unable to instantiate redirectResolver", e);
147            }
148        }
149        if (redirectResolver == null) {
150            redirectResolver = new DefaultRedirectResolver();
151        }
152    }
153
154    @Override
155    public void stop(ComponentContext context) throws InterruptedException {
156        super.stop(context);
157        redirectResolver = null;
158    }
159
160    /**
161     * {@inheritDoc} Multipart download are not yet supported. You can only provide a blob singleton at this time.
162     */
163    @Override
164    public String storeBlobs(List<Blob> blobs) {
165        if (blobs.size() > 1) {
166            throw new IllegalArgumentException("multipart download not yet implemented");
167        }
168        TransientStore ts = Framework.getService(TransientStoreService.class).getStore(TRANSIENT_STORE_STORE_NAME);
169        String storeKey = UUID.randomUUID().toString();
170        ts.putBlobs(storeKey, blobs);
171        ts.setCompleted(storeKey, true);
172        return storeKey;
173    }
174
175    @Override
176    public String getFullDownloadUrl(DocumentModel doc, String xpath, Blob blob, String baseUrl) {
177        ConfigurationService configurationService = Framework.getService(ConfigurationService.class);
178        if (configurationService.isBooleanTrue(DOWNLOAD_URL_FOLLOW_REDIRECT)) {
179            try {
180                URI uri = redirectResolver.getURI(blob, UsageHint.DOWNLOAD, null);
181                if (uri != null) {
182                    return uri.toString();
183                }
184            } catch (IOException e) {
185                log.error(e, e);
186            }
187        }
188        return baseUrl + getDownloadUrl(doc, xpath, blob.getFilename());
189    }
190
191    @Override
192    public String getDownloadUrl(DocumentModel doc, String xpath, String filename) {
193        return getDownloadUrl(doc.getRepositoryName(), doc.getId(), xpath, filename, doc.getChangeToken());
194    }
195
196    @Override
197    public String getDownloadUrl(String repositoryName, String docId, String xpath, String filename) {
198        return getDownloadUrl(repositoryName, docId, xpath, filename, null);
199    }
200
201    @Override
202    public String getDownloadUrl(String repositoryName, String docId, String xpath, String filename,
203            String changeToken) {
204        StringBuilder sb = new StringBuilder();
205        sb.append(NXFILE);
206        sb.append("/").append(repositoryName);
207        sb.append("/").append(docId);
208        if (xpath != null) {
209            sb.append("/").append(xpath);
210            if (filename != null) {
211                // make sure filename doesn't contain path separators
212                filename = getSanitizedFilenameWithoutPath(filename);
213                sb.append("/").append(URIUtils.quoteURIPathComponent(filename, true));
214            }
215        }
216        if (StringUtils.isNotEmpty(changeToken)) {
217            try {
218                sb.append("?")
219                  .append(CoreSession.CHANGE_TOKEN)
220                  .append("=")
221                  .append(URLEncoder.encode(changeToken, "UTF-8"));
222            } catch (UnsupportedEncodingException e) {
223                log.error("Cannot append changeToken", e);
224            }
225        }
226        return sb.toString();
227    }
228
229    protected String getSanitizedFilenameWithoutPath(String filename) {
230        int sep = Math.max(filename.lastIndexOf('\\'), filename.lastIndexOf('/'));
231        if (sep != -1) {
232            filename = filename.substring(sep + 1);
233        }
234
235        return FILENAME_SANITIZATION_REGEX.matcher(filename).replaceAll("");
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
247     * returned.
248     *
249     * @param path the path of the URL to use to download blobs
250     * @return the pair download path and action
251     * @since 9.1
252     */
253    protected Pair<String, Action> getDownloadPathAndAction(String path) {
254        if (path.startsWith("/")) {
255            path = path.substring(1);
256        }
257        int slash = path.indexOf('/');
258        if (slash < 0) {
259            return null;
260        }
261
262        // remove query string if any
263        path = path.replaceFirst("\\?.*$", "");
264
265        String type = path.substring(0, slash);
266        String downloadPath = path.substring(slash + 1);
267        switch (type) {
268        case NXDOWNLOADINFO:
269            // used by nxdropout.js
270            return Pair.of(downloadPath, Action.INFO);
271        case NXFILE:
272        case NXBIGFILE:
273            return Pair.of(downloadPath, Action.DOWNLOAD_FROM_DOC);
274        case NXBIGZIPFILE:
275        case NXBIGBLOB:
276            return Pair.of(downloadPath, Action.DOWNLOAD);
277        case NXBLOBSTATUS:
278            return Pair.of(downloadPath, Action.BLOBSTATUS);
279        default:
280            return null;
281        }
282    }
283
284    @Override
285    public Blob resolveBlobFromDownloadUrl(String downloadURL) {
286        Pair<String, Action> pair = getDownloadPathAndAction(downloadURL);
287        if (pair == null) {
288            return null;
289        }
290        String downloadPath = pair.getLeft();
291        try {
292            DownloadBlobInfo downloadBlobInfo = new DownloadBlobInfo(downloadPath);
293            CoreSession session = CoreInstance.getCoreSession(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            Blob blob = resolveBlob(doc, downloadBlobInfo.xpath);
300            if (!checkPermission(doc, downloadBlobInfo.xpath, blob, null, null)) {
301                return null;
302            }
303            return blob;
304        } catch (IllegalArgumentException e) {
305            return null;
306        }
307    }
308
309    @Override
310    public void handleDownload(HttpServletRequest req, HttpServletResponse resp, String baseUrl, String path)
311            throws IOException {
312        Pair<String, Action> pair = getDownloadPathAndAction(path);
313        if (pair == null) {
314            resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Invalid URL syntax");
315            return;
316        }
317        String downloadPath = pair.getLeft();
318        Action action = pair.getRight();
319        switch (action) {
320        case INFO:
321            handleDownload(req, resp, downloadPath, baseUrl, true);
322            break;
323        case DOWNLOAD_FROM_DOC:
324            handleDownload(req, resp, downloadPath, baseUrl, false);
325            break;
326        case DOWNLOAD:
327            downloadBlob(req, resp, downloadPath, "download");
328            break;
329        case BLOBSTATUS:
330            downloadBlobStatus(req, resp, downloadPath, "download");
331            break;
332        default:
333            resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Invalid URL syntax");
334        }
335    }
336
337    /* Needed because Chemistry wraps HEAD requests to pretend they are GET requests. */
338    protected static final String CHEMISTRY_HEAD_REQUEST_CLASS = "HEADHttpServletRequestWrapper";
339
340    protected static boolean isHead(HttpServletRequest request) {
341        return "HEAD".equals(request.getMethod()) || request.getClass().getSimpleName().equals(CHEMISTRY_HEAD_REQUEST_CLASS);
342    }
343
344    protected void handleDownload(HttpServletRequest req, HttpServletResponse resp, String downloadPath, String baseUrl,
345            boolean info) throws IOException {
346        boolean tx = false;
347        DownloadBlobInfo downloadBlobInfo;
348        try {
349            downloadBlobInfo = new DownloadBlobInfo(downloadPath);
350        } catch (IllegalArgumentException e) {
351            resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Invalid URL syntax");
352            return;
353        }
354
355        try {
356            if (!TransactionHelper.isTransactionActive()) {
357                // Manually start and stop a transaction around repository access to be able to release transactional
358                // resources without waiting for the download that can take a long time (longer than the transaction
359                // timeout) especially if the client or the connection is slow.
360                tx = TransactionHelper.startTransaction();
361            }
362            String xpath = downloadBlobInfo.xpath;
363            String filename = downloadBlobInfo.filename;
364            CoreSession session = CoreInstance.getCoreSession(downloadBlobInfo.repository);
365            DocumentRef docRef = new IdRef(downloadBlobInfo.docId);
366            if (!session.exists(docRef)) {
367                // Send a security exception to force authentication, if the current user is anonymous
368                NuxeoPrincipal principal = NuxeoPrincipal.getCurrent();
369                if (principal != null && principal.isAnonymous()) {
370                    throw new DocumentSecurityException("Authentication is needed for downloading the blob");
371                }
372                resp.sendError(HttpServletResponse.SC_NOT_FOUND, "No document found");
373                return;
374            }
375            DocumentModel doc = session.getDocument(docRef);
376            if (info) {
377                Blob blob = resolveBlob(doc, xpath);
378                if (blob == null) {
379                    resp.sendError(HttpServletResponse.SC_NOT_FOUND, "No blob found");
380                    return;
381                }
382                String downloadUrl = baseUrl + getDownloadUrl(doc, xpath, filename);
383                String result = blob.getMimeType() + ':' + URLEncoder.encode(blob.getFilename(), "UTF-8") + ':'
384                        + downloadUrl;
385                resp.setContentType("text/plain");
386                if (!isHead(req)) {
387                    resp.getWriter().write(result);
388                    resp.getWriter().flush();
389                }
390            } else {
391                DownloadContext context = DownloadContext.builder(req, resp)
392                                                         .doc(doc)
393                                                         .xpath(xpath)
394                                                         .filename(filename)
395                                                         .reason("download")
396                                                         .build();
397                downloadBlob(context);
398            }
399        } catch (NuxeoException e) {
400            if (tx) {
401                TransactionHelper.setTransactionRollbackOnly();
402            }
403            throw new IOException(e);
404        } finally {
405            if (tx) {
406                TransactionHelper.commitOrRollbackTransaction();
407            }
408        }
409    }
410
411    @Override
412    public void downloadBlobStatus(HttpServletRequest request, HttpServletResponse response, String key, String reason)
413            throws IOException {
414        downloadBlob(request, response, key, reason, true);
415    }
416
417    @Override
418    public void downloadBlob(HttpServletRequest request, HttpServletResponse response, String key, String reason)
419            throws IOException {
420        downloadBlob(request, response, key, reason, false);
421    }
422
423    protected void downloadBlob(HttpServletRequest request, HttpServletResponse response, String key, String reason,
424            boolean status) throws IOException {
425        TransientStore ts = Framework.getService(TransientStoreService.class).getStore(TRANSIENT_STORE_STORE_NAME);
426        if (!ts.exists(key)) {
427            response.sendError(HttpServletResponse.SC_NOT_FOUND);
428            return;
429        }
430        List<Blob> blobs = ts.getBlobs(key);
431        if (blobs == null || blobs.isEmpty()) {
432            response.sendError(HttpServletResponse.SC_NOT_FOUND);
433            return;
434        }
435        if (blobs.size() > 1) {
436            throw new IllegalArgumentException("multipart download not yet implemented");
437        }
438        if (ts.getParameter(key, TRANSIENT_STORE_PARAM_ERROR) != null) {
439            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
440                    (String) ts.getParameter(key, TRANSIENT_STORE_PARAM_ERROR));
441            return;
442        }
443        boolean isCompleted = ts.isCompleted(key);
444        if (!status && !isCompleted) {
445            response.setStatus(HttpServletResponse.SC_ACCEPTED);
446            return;
447        }
448        Blob blob;
449        if (status) {
450            Serializable progress = ts.getParameter(key, TRANSIENT_STORE_PARAM_PROGRESS);
451            blob = new AsyncBlob(key, isCompleted, progress != null ? (int) progress : -1);
452        } else {
453            blob = blobs.get(0);
454        }
455        try {
456            DownloadContext context = DownloadContext.builder(request, response)
457                                                     .blob(blob)
458                                                     .reason(reason)
459                                                     .build();
460            downloadBlob(context);
461        } finally {
462            if (!status && !isHead(request)) {
463                ts.remove(key);
464            }
465        }
466    }
467
468    @Deprecated
469    @Override
470    public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath,
471            Blob blob, String filename, String reason) throws IOException {
472        DownloadContext context = DownloadContext.builder(request, response)
473                                                 .doc(doc)
474                                                 .xpath(xpath)
475                                                 .blob(blob)
476                                                 .filename(filename)
477                                                 .reason(reason)
478                                                 .build();
479        downloadBlob(context);
480    }
481
482    @Deprecated
483    @Override
484    public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath,
485            Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos) throws IOException {
486        DownloadContext context = DownloadContext.builder(request, response)
487                                                 .doc(doc)
488                                                 .xpath(xpath)
489                                                 .blob(blob)
490                                                 .filename(filename)
491                                                 .reason(reason)
492                                                 .extendedInfos(extendedInfos)
493                                                 .build();
494        downloadBlob(context);
495    }
496
497    @Deprecated
498    @Override
499    public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath,
500            Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos, Boolean inline)
501            throws IOException {
502        DownloadContext context = DownloadContext.builder(request, response)
503                                                 .doc(doc)
504                                                 .xpath(xpath)
505                                                 .blob(blob)
506                                                 .filename(filename)
507                                                 .reason(reason)
508                                                 .extendedInfos(extendedInfos)
509                                                 .inline(inline)
510                                                 .build();
511        downloadBlob(context);
512    }
513
514    @Deprecated
515    @Override
516    public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath,
517            Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos, Boolean inline,
518            Consumer<ByteRange> blobTransferer) throws IOException {
519        DownloadContext context = DownloadContext.builder(request, response)
520                                                 .doc(doc)
521                                                 .xpath(xpath)
522                                                 .blob(blob)
523                                                 .filename(filename)
524                                                 .reason(reason)
525                                                 .extendedInfos(extendedInfos)
526                                                 .inline(inline)
527                                                 .blobTransferer(blobTransferer)
528                                                 .build();
529        downloadBlob(context);
530    }
531
532    @Override
533    public void downloadBlob(DownloadContext context) throws IOException {
534        HttpServletRequest request = context.getRequest();
535        HttpServletResponse response = context.getResponse();
536        DocumentModel doc = context.getDocumentModel();
537        String xpath = context.getXPath();
538        Blob blob = context.getBlob();
539        if (blob == null) {
540            if (doc == null) {
541                throw new NuxeoException("No doc specified");
542            }
543            blob = resolveBlob(doc, xpath);
544            if (blob == null) {
545                response.sendError(HttpServletResponse.SC_NOT_FOUND, "No blob found");
546                return;
547            }
548        }
549        String filename = context.getFilename();
550        if (filename == null) {
551            filename = blob.getFilename();
552        }
553        String reason = context.getReason();
554        String requestReason = (String) request.getAttribute(REQUEST_ATTR_DOWNLOAD_REASON);
555        if (requestReason != null) {
556            reason = requestReason;
557        }
558        Map<String, Serializable> extendedInfos = context.getExtendedInfos();
559        extendedInfos = extendedInfos == null ? new HashMap<>() : new HashMap<>(extendedInfos);
560        String requestRendition = (String) request.getAttribute(REQUEST_ATTR_DOWNLOAD_RENDITION);
561        if (requestRendition != null) {
562            extendedInfos.put(EXTENDED_INFO_RENDITION, requestRendition);
563        }
564        Boolean inline = context.getInline();
565        Consumer<ByteRange> blobTransferer = context.getBlobTransferer();
566        if (blobTransferer == null) {
567            Blob fblob = blob;
568            blobTransferer = byteRange -> transferBlobWithByteRange(fblob, byteRange, response);
569        }
570        Calendar lastModified = context.getLastModified();
571        if (lastModified == null && doc != null) {
572            try {
573                lastModified = (Calendar) doc.getPropertyValue(DC_MODIFIED);
574            } catch (PropertyNotFoundException | ClassCastException e) {
575                // ignore
576            }
577        }
578
579        // check blob permissions
580        if (!checkPermission(doc, xpath, blob, reason, extendedInfos)) {
581            response.sendError(HttpServletResponse.SC_FORBIDDEN, "Permission denied");
582            return;
583        }
584
585        // check Blob Manager external download link
586        URI uri = redirectResolver.getURI(blob, UsageHint.DOWNLOAD, request);
587        if (uri != null) {
588            try {
589                extendedInfos.put("redirect", uri.toString());
590                logDownload(request, doc, xpath, filename, reason, extendedInfos);
591                response.sendRedirect(uri.toString());
592            } catch (IOException ioe) {
593                DownloadHelper.handleClientDisconnect(ioe);
594            }
595            return;
596        }
597
598        try {
599            String contentType = blob.getMimeType();
600            // empty is true for an unavailable lazy rendition
601            boolean empty = contentType != null && contentType.contains("empty=true");
602
603            long length = blob.getLength();
604            ByteRange byteRange = getByteRange(request, length);
605
606            String digest = blob.getDigest();
607            String digestAlgorithm = blob.getDigestAlgorithm();
608            if (digest == null) {
609                digest = DigestUtils.md5Hex(blob.getStream());
610                digestAlgorithm = MD5;
611            }
612
613            // Want-Digest / Digest
614            Set<String> wantDigests = getWantDigests(request);
615            if (!wantDigests.isEmpty()) {
616                if (wantDigests.contains(digestAlgorithm.toLowerCase())) {
617                    // Digest header (RFC3230)
618                    response.setHeader("Digest", digestAlgorithm + '=' + hexToBase64(digest));
619                }
620                if (wantDigests.contains("contentmd5")) {
621                    // Content-MD5 header (RFC1864)
622                    // deprecated per RFC7231 Appendix B
623                    // don't do it if there's a byte range because the spec is inconsistent
624                    // see https://trac.ietf.org/trac/httpbis/ticket/178
625                    if (byteRange == null && MD5.equalsIgnoreCase(digestAlgorithm)) {
626                        response.setHeader("Content-MD5", hexToBase64(digest));
627                    }
628                }
629            }
630
631            addCacheControlHeaders(request, response);
632
633            // If-Modified-Since / Last-Modified
634            if (!empty && lastModified != null) {
635                long lastModifiedMillis = lastModified.getTimeInMillis();
636                response.setDateHeader("Last-Modified", lastModifiedMillis);
637                long ifModifiedSince;
638                try {
639                    ifModifiedSince = request.getDateHeader("If-Modified-Since");
640                } catch (IllegalArgumentException e) {
641                    log.debug("Invalid If-Modified-Since header", e);
642                    ifModifiedSince = -1;
643                }
644                if (ifModifiedSince != -1 && ifModifiedSince >= lastModifiedMillis) {
645                    // not modified
646                    response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
647                    return;
648                }
649            }
650
651            // If-None-Match / ETag
652            if (!empty) {
653                String etag = '"' + digest + '"'; // with quotes per RFC7232 2.3
654                response.setHeader("ETag", etag); // re-send even on SC_NOT_MODIFIED
655                String ifNoneMatch = request.getHeader("If-None-Match");
656                if (ifNoneMatch != null) {
657                    boolean match = false;
658                    if (ifNoneMatch.equals("*")) {
659                        match = true;
660                    } else {
661                        for (String previousEtag : StringUtils.split(ifNoneMatch, ", ")) {
662                            if (previousEtag.equals(etag)) {
663                                match = true;
664                                break;
665                            }
666                        }
667                    }
668                    if (match) {
669                        String method = request.getMethod();
670                        if (method.equals("GET") || method.equals("HEAD")) {
671                            response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
672                        } else {
673                            // per RFC7232 3.2
674                            response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
675                        }
676                        return;
677                    }
678                }
679            }
680
681            // regular processing
682
683            if (StringUtils.isBlank(filename)) {
684                filename = StringUtils.defaultIfBlank(blob.getFilename(), "file");
685            }
686            String contentDisposition = DownloadHelper.getRFC2231ContentDisposition(request, filename, inline);
687            response.setHeader("Content-Disposition", contentDisposition);
688            response.setContentType(contentType);
689            if (StringUtils.isNotBlank(blob.getEncoding())) {
690                try {
691                    response.setCharacterEncoding(blob.getEncoding());
692                } catch (IllegalArgumentException e) {
693                    // ignore invalid encoding
694                }
695            }
696
697            response.setHeader("Accept-Ranges", "bytes");
698            if (byteRange != null) {
699                response.setHeader("Content-Range",
700                        "bytes " + byteRange.getStart() + "-" + byteRange.getEnd() + "/" + length);
701                response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
702            }
703            long contentLength = byteRange == null ? length : byteRange.getLength();
704            response.setContentLengthLong(contentLength);
705
706            // log the download but not if it's a random byte range
707            if (byteRange == null || byteRange.getStart() == 0) {
708                logDownload(request, doc, xpath, filename, reason, extendedInfos);
709            }
710
711            String xAccelLocation = request.getHeader(NginxConstants.X_ACCEL_LOCATION_HEADER);
712            if (Framework.isBooleanPropertyTrue(NginxConstants.X_ACCEL_ENABLED)
713                    && StringUtils.isNotEmpty(xAccelLocation)) {
714                File file = blob.getFile();
715                if (file != null && blob instanceof ManagedBlob) {
716                    File storageDir;
717                    BlobManager blobManager = Framework.getService(BlobManager.class);
718                    BlobProvider blobProvider = blobManager.getBlobProvider(blob);
719                    if (blobProvider instanceof LocalBlobProvider) {
720                        storageDir = ((LocalBlobProvider) blobProvider).getStorageDir().toFile();
721                    } else if (blobProvider.getBinaryManager() instanceof DefaultBinaryManager) {
722                        storageDir = ((DefaultBinaryManager) blobProvider.getBinaryManager()).getStorageDir();
723                    } else {
724                        throw new NuxeoException("Cannot use Nginx accelerated download with blob provider: "
725                                + blobProvider.getClass().getName());
726                    }
727                    String relative = storageDir.toURI().relativize(file.toURI()).getPath();
728                    if (xAccelLocation.endsWith("/")) {
729                        xAccelLocation = xAccelLocation + relative;
730                    } else {
731                        xAccelLocation = xAccelLocation + "/" + relative;
732                    }
733                    response.setHeader(NginxConstants.X_ACCEL_REDIRECT_HEADER, xAccelLocation);
734                    return;
735                }
736            }
737
738            if (!isHead(request)) {
739                // execute the final download
740                blobTransferer.accept(byteRange);
741            }
742        } catch (UncheckedIOException e) {
743            DownloadHelper.handleClientDisconnect(e.getCause());
744        } catch (IOException ioe) {
745            DownloadHelper.handleClientDisconnect(ioe);
746        }
747    }
748
749    protected ByteRange getByteRange(HttpServletRequest request, long length) {
750        String range = request.getHeader("Range");
751        if (StringUtils.isBlank(range)) {
752            return null;
753        }
754        ByteRange byteRange = DownloadHelper.parseRange(range, length);
755        if (byteRange == null) {
756            log.debug("Invalid byte range received: {}", range);
757        }
758        return byteRange;
759    }
760
761    protected Set<String> getWantDigests(HttpServletRequest request) {
762        Enumeration<String> values = request.getHeaders("Want-Digest");
763        if (values == null) {
764            return Collections.emptySet();
765        }
766        Set<String> wantDigests = new HashSet<>();
767        for (String value : Collections.list(values)) {
768            int semicolon = value.indexOf(';');
769            if (semicolon >= 0) {
770                value = value.substring(0, semicolon);
771            }
772            wantDigests.add(value.trim().toLowerCase());
773        }
774        return wantDigests;
775    }
776
777    protected static String hexToBase64(String hexString) {
778        try {
779            return Base64.encodeBase64String(Hex.decodeHex(hexString.toCharArray()));
780        } catch (DecoderException e) {
781            throw new NuxeoException(e);
782        }
783    }
784
785    protected void transferBlobWithByteRange(Blob blob, ByteRange byteRange, HttpServletResponse response) {
786        transferBlobWithByteRange(blob, byteRange, () -> {
787            try {
788                return response.getOutputStream();
789            } catch (IOException e) {
790                throw new UncheckedIOException(e);
791            }
792        });
793        try {
794            response.flushBuffer();
795        } catch (IOException e) {
796            throw new UncheckedIOException(e);
797        }
798    }
799
800    @Override
801    public void transferBlobWithByteRange(Blob blob, ByteRange byteRange, Supplier<OutputStream> outputStreamSupplier) {
802        try (InputStream in = blob.getStream()) {
803            @SuppressWarnings("resource")
804            OutputStream out = outputStreamSupplier.get(); // not ours to close
805            BufferingServletOutputStream.stopBuffering(out);
806            if (byteRange == null) {
807                IOUtils.copy(in, out);
808            } else {
809                @SuppressWarnings("resource") // closing the original stream is enough
810                InputStream substream = byteRange.forStream(in);
811                // don't use IOUtils.copyLarge because it uses a skip method that reads
812                // all intervening bytes, which is inefficient for skippable streams
813                IOUtils.copy(substream, out);
814            }
815            out.flush();
816        } catch (IOException e) {
817            throw new UncheckedIOException(e);
818        }
819    }
820
821    protected String fixXPath(String xpath) {
822        // Hack for Flash Url wich doesn't support ':' char
823        return xpath == null ? null : xpath.replace(';', ':');
824    }
825
826    @Override
827    public Blob resolveBlob(DocumentModel doc) {
828        BlobHolderAdapterService blobHolderAdapterService = Framework.getService(BlobHolderAdapterService.class);
829        return blobHolderAdapterService.getBlobHolderAdapter(doc, "download").getBlob();
830    }
831
832    @Override
833    public Blob resolveBlob(DocumentModel doc, String xpath) {
834        if (xpath == null) {
835            return resolveBlob(doc);
836        }
837        xpath = fixXPath(xpath);
838        Blob blob;
839        if (xpath.startsWith(BLOBHOLDER_PREFIX)) {
840            BlobHolder bh = doc.getAdapter(BlobHolder.class);
841            if (bh == null) {
842                log.debug("{} is not a BlobHolder", doc);
843                return null;
844            }
845            String suffix = xpath.substring(BLOBHOLDER_PREFIX.length());
846            int index;
847            try {
848                index = Integer.parseInt(suffix);
849            } catch (NumberFormatException e) {
850                log.debug(e.getMessage());
851                return null;
852            }
853            if (!suffix.equals(Integer.toString(index))) {
854                // attempt to use a non-canonical integer, could be used to bypass
855                // a permission function checking just "blobholder:1" and receiving "blobholder:01"
856                log.debug("Non-canonical index: {}", suffix);
857                return null;
858            }
859            if (index == 0) {
860                blob = bh.getBlob();
861            } else {
862                blob = bh.getBlobs().get(index);
863            }
864        } else {
865            if (!xpath.contains(":")) {
866                // attempt to use a xpath not prefix-qualified, could be used to bypass
867                // a permission function checking just "file:content" and receiving "content"
868                log.debug("Non-canonical xpath: {}", xpath);
869                return null;
870            }
871            try {
872                blob = (Blob) doc.getPropertyValue(xpath);
873            } catch (PropertyNotFoundException e) {
874                log.debug("Property '{}' not found", xpath, e);
875                return null;
876            }
877        }
878        return blob;
879    }
880
881    @Override
882    public boolean checkPermission(DocumentModel doc, String xpath, Blob blob, String reason,
883            Map<String, Serializable> extendedInfos) {
884        List<DownloadPermissionDescriptor> descriptors = getDescriptors(XP_PERMISSIONS);
885        if (descriptors.isEmpty()) {
886            return true;
887        }
888        xpath = fixXPath(xpath);
889        Map<String, Object> context = new HashMap<>();
890        Map<String, Serializable> ei = extendedInfos == null ? Collections.emptyMap() : extendedInfos;
891        NuxeoPrincipal currentUser = NuxeoPrincipal.getCurrent();
892        context.put("Document", doc);
893        context.put("XPath", xpath);
894        context.put("Blob", blob);
895        context.put("Reason", reason);
896        context.put("Infos", ei);
897        context.put("Rendition", ei.get(EXTENDED_INFO_RENDITION));
898        context.put("CurrentUser", currentUser);
899        for (DownloadPermissionDescriptor descriptor : descriptors) {
900            ScriptEngine engine = scriptEngineManager.getEngineByName(descriptor.getScriptLanguage());
901            if (engine == null) {
902                throw new NuxeoException("Engine not found for language: " + descriptor.getScriptLanguage()
903                        + " in permission: " + descriptor.name);
904            }
905            if (!(engine instanceof Invocable)) {
906                throw new NuxeoException("Engine " + engine.getClass().getName() + " not Invocable for language: "
907                        + descriptor.getScriptLanguage() + " in permission: " + descriptor.name);
908            }
909            Object result;
910            try {
911                engine.eval(descriptor.script);
912                engine.getBindings(ScriptContext.ENGINE_SCOPE).putAll(context);
913                result = ((Invocable) engine).invokeFunction(RUN_FUNCTION);
914            } catch (NoSuchMethodException e) {
915                throw new NuxeoException("Script does not contain function: " + RUN_FUNCTION + "() in permission: "
916                        + descriptor.name, e);
917            } catch (ScriptException e) {
918                log.error("Failed to evaluate script: {}", descriptor.name, e);
919                continue;
920            }
921            if (!(result instanceof Boolean)) {
922                log.error("Failed to get boolean result from permission: {} ({})", descriptor.name, result);
923                continue;
924            }
925            boolean allow = ((Boolean) result).booleanValue();
926            if (!allow) {
927                return false;
928            }
929        }
930        return true;
931    }
932
933    /**
934     * Internet Explorer file downloads over SSL do not work with certain HTTP cache control headers
935     * <p>
936     * See http://support.microsoft.com/kb/323308/
937     * <p>
938     * What is not mentioned in the above Knowledge Base is that "Pragma: no-cache" also breaks download in MSIE over
939     * SSL
940     */
941    protected void addCacheControlHeaders(HttpServletRequest request, HttpServletResponse response) {
942        String userAgent = request.getHeader("User-Agent");
943        boolean secure = request.isSecure();
944        if (!secure) {
945            String nvh = request.getHeader(NUXEO_VIRTUAL_HOST);
946            if (nvh == null) {
947                nvh = Framework.getProperty(VH_PARAM);
948            }
949            if (nvh != null) {
950                secure = nvh.startsWith("https");
951            }
952        }
953        if (userAgent != null && userAgent.contains("MSIE") && (secure || forceNoCacheOnMSIE())) {
954            String cacheControl = "max-age=15, must-revalidate";
955            log.debug("Setting Cache-Control: {}",  cacheControl);
956            response.setHeader("Cache-Control", cacheControl);
957        }
958    }
959
960    protected static boolean forceNoCacheOnMSIE() {
961        // see NXP-7759
962        return Framework.isBooleanPropertyTrue(FORCE_NO_CACHE_ON_MSIE);
963    }
964
965    @Override
966    public void logDownload(HttpServletRequest request, DocumentModel doc, String xpath, String filename, String reason,
967            Map<String, Serializable> extendedInfos) {
968        if (request != null && isHead(request)) {
969            // don't log HEAD requests
970            return;
971        }
972        if ("webengine".equals(reason)) {
973            // don't log JSON operation results as downloads
974            return;
975        }
976        EventService eventService = Framework.getService(EventService.class);
977        if (eventService == null) {
978            return;
979        }
980        EventContext ctx;
981        if (doc != null) {
982            CoreSession session = doc.getCoreSession();
983            NuxeoPrincipal principal = session == null ? getPrincipal() : session.getPrincipal();
984            ctx = new DocumentEventContext(session, principal, doc);
985            ctx.setProperty(CoreEventConstants.REPOSITORY_NAME, doc.getRepositoryName());
986        } else {
987            ctx = new EventContextImpl(null, getPrincipal());
988        }
989        Map<String, Serializable> map = new HashMap<>();
990        map.put("blobXPath", xpath);
991        map.put("blobFilename", filename);
992        map.put("downloadReason", reason);
993        if (extendedInfos != null) {
994            map.putAll(extendedInfos);
995        }
996        ctx.setProperty("extendedInfos", (Serializable) map);
997        ctx.setProperty("comment", filename);
998        Event event = ctx.newEvent(EVENT_NAME);
999        eventService.fireEvent(event);
1000    }
1001
1002    protected static NuxeoPrincipal getPrincipal() {
1003        return NuxeoPrincipal.getCurrent();
1004    }
1005
1006}