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