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;
022
023import javax.servlet.http.HttpServletRequest;
024
025import org.apache.commons.lang3.StringUtils;
026import org.apache.commons.logging.Log;
027import org.apache.commons.logging.LogFactory;
028import org.nuxeo.common.utils.RFC2231;
029import org.nuxeo.ecm.core.api.Blob;
030import org.nuxeo.ecm.core.blob.ByteRange;
031
032/**
033 * Helper class related to the download service.
034 *
035 * @since 7.3
036 */
037public class DownloadHelper {
038
039    private static final Log log = LogFactory.getLog(DownloadHelper.class);
040
041    public static final String INLINE = "inline";
042
043    // tomcat catalina
044    private static final String CLIENT_ABORT_EXCEPTION = "ClientAbortException";
045
046    // jetty (with CamelCase "Eof")
047    private static final String EOF_EXCEPTION = "EofException";
048
049    // utility class
050    private DownloadHelper() {
051    }
052
053    /**
054     * Parses a byte range.
055     *
056     * @param range the byte range as a string
057     * @param length the file length
058     * @return the byte range, or {@code null} if it couldn't be parsed.
059     */
060    public static ByteRange parseRange(String range, long length) {
061        try {
062            // TODO does no support multiple ranges
063            if (!range.startsWith("bytes=") || range.indexOf(',') >= 0) {
064                return null;
065            }
066            int i = range.indexOf('-', 6);
067            if (i < 0) {
068                return null;
069            }
070            String start = range.substring(6, i).trim();
071            String end = range.substring(i + 1).trim();
072            long rangeStart = 0;
073            long rangeEnd = length - 1;
074            if (start.isEmpty()) {
075                if (end.isEmpty()) {
076                    return null;
077                }
078                rangeStart = length - Long.parseLong(end);
079                if (rangeStart < 0) {
080                    rangeStart = 0;
081                }
082            } else {
083                rangeStart = Long.parseLong(start);
084                if (!end.isEmpty()) {
085                    rangeEnd = Long.parseLong(end);
086                }
087            }
088            if (rangeStart > rangeEnd) {
089                return null;
090            }
091            return ByteRange.inclusive(rangeStart, rangeEnd);
092        } catch (NumberFormatException e) {
093            return null;
094        }
095    }
096
097    /**
098     * Generates a {@code Content-Disposition} string based on the servlet request for a given filename.
099     * <p>
100     * The value follows RFC2231.
101     *
102     * @param request the http servlet request
103     * @param filename the filename
104     * @return a full string to set as value of a {@code Content-Disposition} header
105     */
106    public static String getRFC2231ContentDisposition(HttpServletRequest request, String filename) {
107        return getRFC2231ContentDisposition(request, filename, null);
108    }
109
110    /**
111     * Generates a {@code Content-Disposition} string for a given filename.
112     * <p>
113     * The value follows RFC2231.
114     *
115     * @param request the http servlet request
116     * @param filename the filename
117     * @param inline how to set the content disposition; {@code TRUE} for {@code inline}, {@code FALSE} for
118     *            {@code attachment}, or {@code null} to detect from {@code inline} request parameter or attribute
119     * @return a full string to set as value of a {@code Content-Disposition} header
120     * @since 7.10
121     */
122    public static String getRFC2231ContentDisposition(HttpServletRequest request, String filename, Boolean inline) {
123        String userAgent = request.getHeader("User-Agent");
124        boolean binline;
125        if (inline == null) {
126            String inlineParam = request.getParameter(INLINE);
127            if (inlineParam == null) {
128                inlineParam = (String) request.getAttribute(INLINE);
129            }
130            binline = inlineParam != null && !"false".equals(inlineParam);
131        } else {
132            binline = inline.booleanValue();
133        }
134        return RFC2231.encodeContentDisposition(filename, binline, userAgent);
135    }
136
137    /**
138     * Gets the Content-Type header for the given blob, per RFC 2616.
139     * <p>
140     * For example {@code application/pdf} or {@code text/plain; charset=ISO-8859-1}.
141     *
142     * @param blob the blob
143     * @return the header, or {@code null} if the blob has no content type information
144     * @since 11.1
145     */
146    public static String getContentTypeHeader(Blob blob) {
147        String contentType = blob.getMimeType();
148        String encoding = blob.getEncoding();
149        if (contentType != null && StringUtils.isNotBlank(encoding)) {
150            int i = contentType.indexOf(';');
151            if (i >= 0) {
152                contentType = contentType.substring(0, i);
153            }
154            contentType += "; charset=" + encoding;
155        }
156        return contentType;
157    }
158
159    public static boolean isClientAbortError(Throwable t) {
160        int loops = 20; // no infinite loop
161        while (t != null && loops > 0) {
162            if (t instanceof IOException) {
163                // handle all IOException that are ClientAbortException by looking
164                // at their class name since the package name is not the same for
165                // jboss, glassfish, tomcat and jetty and we don't want to add
166                // implementation specific build dependencies to this project
167                String name = t.getClass().getSimpleName();
168                if (CLIENT_ABORT_EXCEPTION.equals(name) || EOF_EXCEPTION.equals(name)) {
169                    return true;
170                }
171            }
172            loops--;
173            t = t.getCause();
174        }
175        return false;
176    }
177
178    public static void logClientAbort(Throwable t) {
179        log.debug("Client disconnected: " + unwrapException(t).getMessage());
180    }
181
182    private static Throwable unwrapException(Throwable t) {
183        while (t.getCause() != null) {
184            t = t.getCause();
185        }
186        return t;
187    }
188
189    /**
190     * Re-throws the passed exception except if it corresponds to a client disconnect, for which logging doesn't bring
191     * us anything.
192     *
193     * @param e the original exception
194     * @throws IOException if this is not a client disconnect
195     */
196    public static void handleClientDisconnect(IOException e) throws IOException {
197        if (isClientAbortError(e)) {
198            logClientAbort(e);
199        } else {
200            // unexpected problem, let traditional error management handle it
201            throw e; // NOSONAR
202        }
203    }
204
205}