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