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