001/*
002 * (C) Copyright 2015 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 */
019package org.nuxeo.ecm.core.io.download;
020
021import java.io.IOException;
022import java.io.InputStream;
023import java.io.OutputStream;
024import java.io.Serializable;
025import java.io.UncheckedIOException;
026import java.net.URI;
027import java.security.Principal;
028import java.util.ArrayList;
029import java.util.Collections;
030import java.util.HashMap;
031import java.util.List;
032import java.util.Map;
033import java.util.Objects;
034import java.util.function.Consumer;
035import java.util.function.Supplier;
036
037import javax.script.Invocable;
038import javax.script.ScriptContext;
039import javax.script.ScriptEngine;
040import javax.script.ScriptEngineManager;
041import javax.script.ScriptException;
042import javax.servlet.http.HttpServletRequest;
043import javax.servlet.http.HttpServletResponse;
044
045import org.apache.commons.io.IOUtils;
046import org.apache.commons.lang.StringUtils;
047import org.apache.commons.logging.Log;
048import org.apache.commons.logging.LogFactory;
049import org.nuxeo.common.utils.URIUtils;
050import org.nuxeo.ecm.core.api.Blob;
051import org.nuxeo.ecm.core.api.CoreSession;
052import org.nuxeo.ecm.core.api.DocumentModel;
053import org.nuxeo.ecm.core.api.NuxeoException;
054import org.nuxeo.ecm.core.api.NuxeoPrincipal;
055import org.nuxeo.ecm.core.api.SystemPrincipal;
056import org.nuxeo.ecm.core.api.blobholder.BlobHolder;
057import org.nuxeo.ecm.core.api.event.CoreEventConstants;
058import org.nuxeo.ecm.core.api.local.ClientLoginModule;
059import org.nuxeo.ecm.core.api.model.PropertyNotFoundException;
060import org.nuxeo.ecm.core.blob.BlobManager;
061import org.nuxeo.ecm.core.blob.BlobManager.UsageHint;
062import org.nuxeo.ecm.core.event.Event;
063import org.nuxeo.ecm.core.event.EventContext;
064import org.nuxeo.ecm.core.event.EventService;
065import org.nuxeo.ecm.core.event.impl.DocumentEventContext;
066import org.nuxeo.ecm.core.event.impl.EventContextImpl;
067import org.nuxeo.runtime.api.Framework;
068import org.nuxeo.runtime.model.ComponentInstance;
069import org.nuxeo.runtime.model.DefaultComponent;
070import org.nuxeo.runtime.model.SimpleContributionRegistry;
071
072/**
073 * This service allows the download of blobs to a HTTP response.
074 *
075 * @since 7.3
076 */
077public class DownloadServiceImpl extends DefaultComponent implements DownloadService {
078
079    private static final Log log = LogFactory.getLog(DownloadServiceImpl.class);
080
081    protected static final int DOWNLOAD_BUFFER_SIZE = 1024 * 512;
082
083    private static final String NUXEO_VIRTUAL_HOST = "nuxeo-virtual-host";
084
085    private static final String VH_PARAM = "nuxeo.virtual.host";
086
087    private static final String FORCE_NO_CACHE_ON_MSIE = "org.nuxeo.download.force.nocache.msie";
088
089    private static final String XP = "permissions";
090
091    private static final String RUN_FUNCTION = "run";
092
093    private DownloadPermissionRegistry registry = new DownloadPermissionRegistry();
094
095    private ScriptEngineManager scriptEngineManager;
096
097    public static class DownloadPermissionRegistry extends SimpleContributionRegistry<DownloadPermissionDescriptor> {
098
099        @Override
100        public String getContributionId(DownloadPermissionDescriptor contrib) {
101            return contrib.getName();
102        }
103
104        @Override
105        public boolean isSupportingMerge() {
106            return true;
107        }
108
109        @Override
110        public DownloadPermissionDescriptor clone(DownloadPermissionDescriptor orig) {
111            return new DownloadPermissionDescriptor(orig);
112        }
113
114        @Override
115        public void merge(DownloadPermissionDescriptor src, DownloadPermissionDescriptor dst) {
116            dst.merge(src);
117        }
118
119        public DownloadPermissionDescriptor getDownloadPermissionDescriptor(String id) {
120            return getCurrentContribution(id);
121        }
122
123        /** Returns descriptors sorted by name. */
124        public List<DownloadPermissionDescriptor> getDownloadPermissionDescriptors() {
125            List<DownloadPermissionDescriptor> descriptors = new ArrayList<>(currentContribs.values());
126            Collections.sort(descriptors);
127            return descriptors;
128        }
129    }
130
131    public DownloadServiceImpl() {
132        scriptEngineManager = new ScriptEngineManager();
133    }
134
135    @Override
136    public void registerContribution(Object contribution, String extensionPoint, ComponentInstance contributor) {
137        if (!XP.equals(extensionPoint)) {
138            throw new UnsupportedOperationException(extensionPoint);
139        }
140        DownloadPermissionDescriptor descriptor = (DownloadPermissionDescriptor) contribution;
141        registry.addContribution(descriptor);
142    }
143
144    @Override
145    public void unregisterContribution(Object contribution, String extensionPoint, ComponentInstance contributor) {
146        DownloadPermissionDescriptor descriptor = (DownloadPermissionDescriptor) contribution;
147        registry.removeContribution(descriptor);
148    }
149
150    @Override
151    public String getDownloadUrl(DocumentModel doc, String xpath, String filename) {
152        return getDownloadUrl(doc.getRepositoryName(), doc.getId(), xpath, filename);
153    }
154
155    @Override
156    public String getDownloadUrl(String repositoryName, String docId, String xpath, String filename) {
157        StringBuilder sb = new StringBuilder();
158        sb.append(NXFILE);
159        sb.append("/");
160        sb.append(repositoryName);
161        sb.append("/");
162        sb.append(docId);
163        if (xpath != null) {
164            sb.append("/");
165            sb.append(xpath);
166            if (filename != null) {
167                sb.append("/");
168                sb.append(URIUtils.quoteURIPathComponent(filename, true));
169            }
170        }
171        return sb.toString();
172    }
173
174    @Override
175    public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath,
176            Blob blob, String filename, String reason) throws IOException {
177        downloadBlob(request, response, doc, xpath, blob, filename, reason, null);
178    }
179
180    @Override
181    public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath,
182            Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos)
183            throws IOException {
184        downloadBlob(request, response, doc, xpath, blob, filename, reason, extendedInfos, null);
185    }
186
187    @Override
188    public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath,
189            Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos, Boolean inline)
190            throws IOException {
191        if (blob == null) {
192            if (doc == null || xpath == null) {
193                throw new NuxeoException("No blob or doc xpath");
194            }
195            blob = resolveBlob(doc, xpath);
196            if (blob == null) {
197                response.sendError(HttpServletResponse.SC_NOT_FOUND, "No blob found");
198                return;
199            }
200        }
201        final Blob fblob = blob;
202        downloadBlob(request, response, doc, xpath, blob, filename, reason, extendedInfos, inline,
203                byteRange -> transferBlobWithByteRange(fblob, byteRange, response));
204    }
205
206    @Override
207    public void downloadBlob(HttpServletRequest request, HttpServletResponse response, DocumentModel doc, String xpath,
208            Blob blob, String filename, String reason, Map<String, Serializable> extendedInfos, Boolean inline,
209            Consumer<ByteRange> blobTransferer) throws IOException {
210        Objects.requireNonNull(blob);
211        // check blob permissions
212        if (!checkPermission(doc, xpath, blob, reason, extendedInfos)) {
213            response.sendError(HttpServletResponse.SC_FORBIDDEN, "Permission denied");
214            return;
215        }
216
217        // check Blob Manager download link
218        BlobManager blobManager = Framework.getService(BlobManager.class);
219        URI uri = blobManager == null ? null : blobManager.getURI(blob, UsageHint.DOWNLOAD, request);
220        if (uri != null) {
221            try {
222                Map<String, Serializable> ei = new HashMap<>();
223                if (extendedInfos != null) {
224                    ei.putAll(extendedInfos);
225                }
226                ei.put("redirect", uri.toString());
227                logDownload(doc, xpath, filename, reason, ei);
228                response.sendRedirect(uri.toString());
229            } catch (IOException ioe) {
230                DownloadHelper.handleClientDisconnect(ioe);
231            }
232            return;
233        }
234
235        try {
236            String etag = '"' + blob.getDigest() + '"'; // with quotes per RFC7232 2.3
237            response.setHeader("ETag", etag); // re-send even on SC_NOT_MODIFIED
238            addCacheControlHeaders(request, response);
239
240            String ifNoneMatch = request.getHeader("If-None-Match");
241            if (ifNoneMatch != null) {
242                boolean match = false;
243                if (ifNoneMatch.equals("*")) {
244                    match = true;
245                } else {
246                    for (String previousEtag : StringUtils.split(ifNoneMatch, ", ")) {
247                        if (previousEtag.equals(etag)) {
248                            match = true;
249                            break;
250                        }
251                    }
252                }
253                if (match) {
254                    String method = request.getMethod();
255                    if (method.equals("GET") || method.equals("HEAD")) {
256                        response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
257                    } else {
258                        // per RFC7232 3.2
259                        response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
260                    }
261                    return;
262                }
263            }
264
265            // regular processing
266
267            if (StringUtils.isBlank(filename)) {
268                filename = StringUtils.defaultIfBlank(blob.getFilename(), "file");
269            }
270            String contentDisposition = DownloadHelper.getRFC2231ContentDisposition(request, filename, inline);
271            response.setHeader("Content-Disposition", contentDisposition);
272            response.setContentType(blob.getMimeType());
273            if (blob.getEncoding() != null) {
274                response.setCharacterEncoding(blob.getEncoding());
275            }
276
277            long length = blob.getLength();
278            response.setHeader("Accept-Ranges", "bytes");
279            String range = request.getHeader("Range");
280            ByteRange byteRange;
281            if (StringUtils.isBlank(range)) {
282                byteRange = null;
283            } else {
284                byteRange = DownloadHelper.parseRange(range, length);
285                if (byteRange == null) {
286                    log.error("Invalid byte range received: " + range);
287                } else {
288                    response.setHeader("Content-Range",
289                            "bytes " + byteRange.getStart() + "-" + byteRange.getEnd() + "/" + length);
290                    response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
291                }
292            }
293            long contentLength = byteRange == null ? length : byteRange.getLength();
294            if (contentLength < Integer.MAX_VALUE) {
295                response.setContentLength((int) contentLength);
296            }
297
298            logDownload(doc, xpath, filename, reason, extendedInfos);
299
300            // execute the final download
301            blobTransferer.accept(byteRange);
302        } catch (UncheckedIOException e) {
303            DownloadHelper.handleClientDisconnect(e.getCause());
304        } catch (IOException ioe) {
305            DownloadHelper.handleClientDisconnect(ioe);
306        }
307    }
308
309    protected void transferBlobWithByteRange(Blob blob, ByteRange byteRange, HttpServletResponse response)
310            throws UncheckedIOException {
311        transferBlobWithByteRange(blob, byteRange, () -> {
312            try {
313                return response.getOutputStream();
314            } catch (IOException e) {
315                throw new UncheckedIOException(e);
316            }
317        });
318        try {
319            response.flushBuffer();
320        } catch (IOException e) {
321            throw new UncheckedIOException(e);
322        }
323    }
324
325    @Override
326    public void transferBlobWithByteRange(Blob blob, ByteRange byteRange, Supplier<OutputStream> outputStreamSupplier)
327            throws UncheckedIOException {
328        try (InputStream in = blob.getStream()) {
329            @SuppressWarnings("resource")
330            OutputStream out = outputStreamSupplier.get(); // not ours to close
331            BufferingServletOutputStream.stopBuffering(out);
332            if (byteRange == null) {
333                IOUtils.copy(in, out);
334            } else {
335                IOUtils.copyLarge(in, out, byteRange.getStart(), byteRange.getLength());
336            }
337            out.flush();
338        } catch (IOException e) {
339            throw new UncheckedIOException(e);
340        }
341    }
342
343    protected String fixXPath(String xpath) {
344        // Hack for Flash Url wich doesn't support ':' char
345        return xpath == null ? null : xpath.replace(';', ':');
346    }
347
348    @Override
349    public Blob resolveBlob(DocumentModel doc, String xpath) {
350        xpath = fixXPath(xpath);
351        Blob blob;
352        if (xpath.startsWith(BLOBHOLDER_PREFIX)) {
353            BlobHolder bh = doc.getAdapter(BlobHolder.class);
354            if (bh == null) {
355                log.debug("Not a BlobHolder");
356                return null;
357            }
358            String suffix = xpath.substring(BLOBHOLDER_PREFIX.length());
359            int index;
360            try {
361                index = Integer.parseInt(suffix);
362            } catch (NumberFormatException e) {
363                log.debug(e.getMessage());
364                return null;
365            }
366            if (!suffix.equals(Integer.toString(index))) {
367                // attempt to use a non-canonical integer, could be used to bypass
368                // a permission function checking just "blobholder:1" and receiving "blobholder:01"
369                log.debug("Non-canonical index: " + suffix);
370                return null;
371            }
372            if (index == 0) {
373                blob = bh.getBlob();
374            } else {
375                blob = bh.getBlobs().get(index);
376            }
377        } else {
378            if (!xpath.contains(":")) {
379                // attempt to use a xpath not prefix-qualified, could be used to bypass
380                // a permission function checking just "file:content" and receiving "content"
381                log.debug("Non-canonical xpath: " + xpath);
382                return null;
383            }
384            try {
385                blob = (Blob) doc.getPropertyValue(xpath);
386            } catch (PropertyNotFoundException e) {
387                log.debug(e.getMessage());
388                return null;
389            }
390        }
391        return blob;
392    }
393
394    @Override
395    public boolean checkPermission(DocumentModel doc, String xpath, Blob blob, String reason,
396            Map<String, Serializable> extendedInfos) {
397        List<DownloadPermissionDescriptor> descriptors = registry.getDownloadPermissionDescriptors();
398        if (descriptors.isEmpty()) {
399            return true;
400        }
401        xpath = fixXPath(xpath);
402        Map<String, Object> context = new HashMap<>();
403        Map<String, Serializable> ei = extendedInfos == null ? Collections.emptyMap() : extendedInfos;
404        NuxeoPrincipal currentUser = ClientLoginModule.getCurrentPrincipal();
405        context.put("Document", doc);
406        context.put("XPath", xpath);
407        context.put("Blob", blob);
408        context.put("Reason", reason);
409        context.put("Infos", ei);
410        context.put("Rendition", ei.get("rendition"));
411        context.put("CurrentUser", currentUser);
412        for (DownloadPermissionDescriptor descriptor : descriptors) {
413            ScriptEngine engine = scriptEngineManager.getEngineByName(descriptor.getScriptLanguage());
414            if (engine == null) {
415                throw new NuxeoException("Engine not found for language: " + descriptor.getScriptLanguage()
416                        + " in permission: " + descriptor.getName());
417            }
418            if (!(engine instanceof Invocable)) {
419                throw new NuxeoException("Engine " + engine.getClass().getName() + " not Invocable for language: "
420                        + descriptor.getScriptLanguage() + " in permission: " + descriptor.getName());
421            }
422            Object result;
423            try {
424                engine.eval(descriptor.getScript());
425                engine.getBindings(ScriptContext.ENGINE_SCOPE).putAll(context);
426                result = ((Invocable) engine).invokeFunction(RUN_FUNCTION);
427            } catch (NoSuchMethodException e) {
428                throw new NuxeoException("Script does not contain function: " + RUN_FUNCTION + "() in permission: "
429                        + descriptor.getName(), e);
430            } catch (ScriptException e) {
431                log.error("Failed to evaluate script: " + descriptor.getName(), e);
432                continue;
433            }
434            if (!(result instanceof Boolean)) {
435                log.error("Failed to get boolean result from permission: " + descriptor.getName() + " (" + result + ")");
436                continue;
437            }
438            boolean allow = ((Boolean) result).booleanValue();
439            if (!allow) {
440                return false;
441            }
442        }
443        return true;
444    }
445
446    /**
447     * Internet Explorer file downloads over SSL do not work with certain HTTP cache control headers
448     * <p>
449     * See http://support.microsoft.com/kb/323308/
450     * <p>
451     * What is not mentioned in the above Knowledge Base is that "Pragma: no-cache" also breaks download in MSIE over
452     * SSL
453     */
454    protected void addCacheControlHeaders(HttpServletRequest request, HttpServletResponse response) {
455        String userAgent = request.getHeader("User-Agent");
456        boolean secure = request.isSecure();
457        if (!secure) {
458            String nvh = request.getHeader(NUXEO_VIRTUAL_HOST);
459            if (nvh == null) {
460                nvh = Framework.getProperty(VH_PARAM);
461            }
462            if (nvh != null) {
463                secure = nvh.startsWith("https");
464            }
465        }
466        String cacheControl;
467        if (userAgent != null && userAgent.contains("MSIE") && (secure || forceNoCacheOnMSIE())) {
468            cacheControl = "max-age=15, must-revalidate";
469        } else {
470            cacheControl = "private, must-revalidate";
471            response.setHeader("Pragma", "no-cache");
472            response.setDateHeader("Expires", 0);
473        }
474        log.debug("Setting Cache-Control: " + cacheControl);
475        response.setHeader("Cache-Control", cacheControl);
476    }
477
478    protected static boolean forceNoCacheOnMSIE() {
479        // see NXP-7759
480        return Framework.isBooleanPropertyTrue(FORCE_NO_CACHE_ON_MSIE);
481    }
482
483    @Override
484    public void logDownload(DocumentModel doc, String xpath, String filename, String reason,
485            Map<String, Serializable> extendedInfos) {
486        EventService eventService = Framework.getService(EventService.class);
487        if (eventService == null) {
488            return;
489        }
490        EventContext ctx;
491        if (doc != null) {
492            @SuppressWarnings("resource")
493            CoreSession session = doc.getCoreSession();
494            Principal principal = session == null ? getPrincipal() : session.getPrincipal();
495            ctx = new DocumentEventContext(session, principal, doc);
496            ctx.setProperty(CoreEventConstants.REPOSITORY_NAME, doc.getRepositoryName());
497            ctx.setProperty(CoreEventConstants.SESSION_ID, doc.getSessionId());
498        } else {
499            ctx = new EventContextImpl(null, getPrincipal());
500        }
501        Map<String, Serializable> map = new HashMap<>();
502        map.put("blobXPath", xpath);
503        map.put("blobFilename", filename);
504        map.put("downloadReason", reason);
505        if (extendedInfos != null) {
506            map.putAll(extendedInfos);
507        }
508        ctx.setProperty("extendedInfos", (Serializable) map);
509        ctx.setProperty("comment", filename);
510        Event event = ctx.newEvent(EVENT_NAME);
511        eventService.fireEvent(event);
512    }
513
514    protected static NuxeoPrincipal getPrincipal() {
515        NuxeoPrincipal principal = ClientLoginModule.getCurrentPrincipal();
516        if (principal == null) {
517            if (!Framework.isTestModeSet()) {
518                throw new NuxeoException("Missing security context, login() not done");
519            }
520            principal = new SystemPrincipal(null);
521        }
522        return principal;
523    }
524
525}