zweb/src/main/java/org/zkoss/web/servlet/http/Https.java

Summary

Maintainability
F
1 wk
Test Coverage
/* Https.java

    Purpose: 
    Description: 
    History:
    2001/11/29 13:53:05, Create, Tom M. Yeh.

Copyright (C) 2001 Potix Corporation. All Rights Reserved.

{{IS_RIGHT
    This program is distributed under LGPL Version 2.1 in the hope that
    it will be useful, but WITHOUT ANY WARRANTY.
}}IS_RIGHT
*/
package org.zkoss.web.servlet.http;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.WeakHashMap;
import java.util.regex.Pattern;
import java.util.zip.GZIPOutputStream;

import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.ServletRequest;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.zkoss.io.Files;
import org.zkoss.io.RepeatableInputStream;
import org.zkoss.io.RepeatableReader;
import org.zkoss.lang.Strings;
import org.zkoss.util.media.Media;
import org.zkoss.web.Attributes;
import org.zkoss.web.servlet.Servlets;
import org.zkoss.web.util.resource.ExtendletContext;

/**
 * The Servlet-related utilities.
 *
 * @author tomyeh
 */
public class Https extends Servlets {
    private static final Logger log = LoggerFactory.getLogger(Https.class);

    /** Compresses the content into an byte array, or null
     * if the browser doesn't support the compression (accept-encoding).
     *
     * @param content1 the first part of the content to compress; null to ignore.
     * If you have multiple input streams, use java.io.SequenceInputStream
     * to concatenate them
     * @param content2 the second part of the content to compress; null to ignore.
     * @return the compressed result in an byte array,
     * null if the browser doesn't support the compression.
     * @since 2.4.1
     */
    public static final byte[] gzip(HttpServletRequest request, HttpServletResponse response, InputStream content1,
            byte[] content2) throws IOException {
        //We check Content-Encoding first to avoid compressing twice
        String ae = request.getHeader("accept-encoding");
        if (ae != null && !response.containsHeader("Content-Encoding")) {
            if (ae.indexOf("gzip") >= 0) {
                response.addHeader("Content-Encoding", "gzip");
                final ByteArrayOutputStream boas = new ByteArrayOutputStream(8192);
                final GZIPOutputStream gzs = new GZIPOutputStream(boas);
                if (content1 != null)
                    Files.copy(gzs, content1);
                if (content2 != null)
                    gzs.write(content2);
                gzs.finish();
                return boas.toByteArray();
                //            } else if (ae.indexOf("deflate") >= 0) {
                //Refer to http://www.gzip.org/zlib/zlib_faq.html#faq38
                //It is not a good idea to zlib (i.e., deflate)
            }
        }
        return null;
    }

    /**
     * Gets the complete server name, including protocol, server, and ports.
     * Example, http://mysite.com:8080
     */
    public static final String getCompleteServerName(HttpServletRequest hreq) {
        final StringBuffer sb = hreq.getRequestURL();
        final String uri = hreq.getRequestURI();
        return sb.substring(0, sb.length() - uri.length());
    }

    /**
     * Gets the complete context path, including protocol, server, ports, and 
     * context.
     * Example, http://mysite.com:8080/we
     */
    public static final String getCompleteContext(HttpServletRequest hreq) {
        final StringBuffer sb = hreq.getRequestURL();
        final String uri = hreq.getRequestURI();
        final String ctx = hreq.getContextPath();
        return sb.substring(0, sb.length() - uri.length() + ctx.length());
    }

    /** Gets the value of the specified cookie, or null if not found.
     * @param name the cookie's name
     */
    public static final String getCookieValue(HttpServletRequest request, String name) {
        final Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (int j = cookies.length; --j >= 0;) {
                if (cookies[j].getName().equals(name))
                    return cookies[j].getValue();
            }
        }
        return null;
    }

    /**
     * Returns the servlet uri of the request.
     * A servlet uri is getServletPath() + getPathInfo().
     * In other words, a servlet uri is a request uri without the context path.
     * <p>However, HttpServletRequest.getRequestURI returns in encoded format,
     * while this method returns in decode format (i.e., %nn is converted).
     */
    public static final String getServletURI(HttpServletRequest request) {
        final String sp = request.getServletPath();
        final String pi = request.getPathInfo();
        if (pi == null || pi.length() == 0)
            return sp;
        if (sp.length() == 0)
            return pi;
        return sp + pi;
    }

    /**
     * Gets the context path of this page.
     * Unlike getContextPath, it detects whether the current page is included.
     *
     * @return "" if request is not a http request
     */
    public static final String getThisContextPath(ServletRequest request) {
        String asyncPath = (String) request.getAttribute(Attributes.ASYNC_CONTEXT_PATH);
        if (asyncPath != null)
            return asyncPath;
        String path = (String) request.getAttribute(Attributes.INCLUDE_CONTEXT_PATH);
        return path != null ? path
                : request instanceof HttpServletRequest ? ((HttpServletRequest) request).getContextPath() : "";
    }

    /**
     * Gets the servlet path of this page.
     * Unlike getServletPath, it detects whether the current page is included.
     *
     * @return "/" if request is not a http request
     */
    public static final String getThisServletPath(ServletRequest request) {
        String path = (String) request.getAttribute(Attributes.INCLUDE_SERVLET_PATH);
        return path != null ? path
                : request instanceof HttpServletRequest ? ((HttpServletRequest) request).getServletPath() : "/";
    }

    /**
     * Gets the request URI of this page.
     * Unlike getRequestURI, it detects whether the current page is included.
     *
     * @return "/" if request is not a http request
     */
    public static final String getThisRequestURI(ServletRequest request) {
        String path = (String) request.getAttribute(Attributes.INCLUDE_REQUEST_URI);
        return path != null ? path
                : request instanceof HttpServletRequest ? ((HttpServletRequest) request).getRequestURI() : "/";
    }

    /**
     * Gets the query string of this page.
     * Unlike getQueryString, it detects whether the current page is included.
     *
     * @return null if request is not a http request
     */
    public static final String getThisQueryString(ServletRequest request) {
        String path = (String) request.getAttribute(Attributes.INCLUDE_QUERY_STRING);
        return path != null || isIncluded(request) || !(request instanceof HttpServletRequest) ? path
                : //null is valid even included
                ((HttpServletRequest) request).getQueryString();
    }

    /**
     * Gets the path info of this page.
     * Unlike getPathInfo, it detects whether the current page is included.
     *
     * @return null if request is not a http request
     */
    public static final String getThisPathInfo(ServletRequest request) {
        String path = (String) request.getAttribute(Attributes.INCLUDE_PATH_INFO);
        return path != null || isIncluded(request) || !(request instanceof HttpServletRequest) ? path
                : //null is valid even included
                ((HttpServletRequest) request).getPathInfo();
    }

    /**
     * Gets the original context path regardless of being forwarded or not.
     * Unlike getContextPath, it won't be affected by forwarding.
     */
    public static final String getOriginContextPath(ServletRequest request) {
        String path = (String) request.getAttribute(Attributes.FORWARD_CONTEXT_PATH);
        return path != null ? path
                : request instanceof HttpServletRequest ? ((HttpServletRequest) request).getContextPath() : "";
    }

    /**
     * Gets the original servlet path regardless of being forwarded or not.
     * Unlike getServletPath, it won't be affected by forwarding.
     */
    public static final String getOriginServletPath(ServletRequest request) {
        String path = (String) request.getAttribute(Attributes.FORWARD_SERVLET_PATH);
        return path != null ? path
                : request instanceof HttpServletRequest ? ((HttpServletRequest) request).getServletPath() : "/";
    }

    /**
     * Gets the request URI regardless of being forwarded or not.
     * Unlike HttpServletRequest.getRequestURI,
     * it won't be affected by forwarding.
     */
    public static final String getOriginRequestURI(ServletRequest request) {
        String path = (String) request.getAttribute(Attributes.FORWARD_REQUEST_URI);
        return path != null ? path
                : request instanceof HttpServletRequest ? ((HttpServletRequest) request).getRequestURI() : "/";
    }

    /**
     * Gets the path info regardless of being forwarded or not.
     * Unlike getPathInfo, it won't be affected by forwarding.
     */
    public static final String getOriginPathInfo(ServletRequest request) {
        String path = (String) request.getAttribute(Attributes.FORWARD_PATH_INFO);
        return path != null ? path : isForwarded(request) ? null
                : //null is valid even included
                request instanceof HttpServletRequest ? ((HttpServletRequest) request).getPathInfo() : null;
    }

    /**
     * Gets the query string regardless of being forwarded or not.
     * Unlike getQueryString, it won't be affected by forwarding.
     */
    public static final String getOriginQueryString(ServletRequest request) {
        String path = (String) request.getAttribute(Attributes.FORWARD_QUERY_STRING);
        return path != null ? path : isForwarded(request) ? null
                : //null is valid even included
                request instanceof HttpServletRequest ? ((HttpServletRequest) request).getQueryString() : null;
    }

    /** Returns the servlet path + path info + query string.
     * Because the path info is decoded, the return string can be considered
     * as decoded. On the other hand {@link #getOriginFullRequest} is in
     * the encoded form.
     * @see #getOriginFullRequest
     */
    public static final String getOriginFullServlet(ServletRequest request) {
        final String qstr = getOriginQueryString(request);
        final String pi = getOriginPathInfo(request);
        if (qstr == null && pi == null)
            return getOriginServletPath(request);

        final StringBuffer sb = new StringBuffer(80).append(getOriginServletPath(request));
        if (pi != null)
            sb.append(pi);
        if (qstr != null)
            sb.append('?').append(qstr);
        return sb.toString();
    }

    /** Returns the request uri + query string.
     * Unlike {@link #getOriginFullServlet}, this is in the encoded form
     * (e.g., %nn still exists, if any).
     * Note: request uri = context path + servlet path + path info.
     */
    public static final String getOriginFullRequest(ServletRequest request) {
        final String qstr = getOriginQueryString(request);
        return qstr != null ? getOriginRequestURI(request) + '?' + qstr : getOriginRequestURI(request);
    }

    /**
     * Redirects to another URL by prefixing the context path and
     * encoding with encodeRedirectURL.
     *
     * <p>It encodes the URI automatically (encodeRedirectURL).
     * Parameters are encoded by
     * {@link Encodes#setToQueryString(StringBuffer,Map)}.
     *
     * <p>Like {@link Encodes#encodeURL}, the servlet context is
     * prefixed if uri starts with "/". In other words, to redirect other
     * application, the complete URL must be used, e.g., http://host/other.
     *
     * <p>Also, HttpServletResponse.encodeRedirectURL is called automatically.
     *
     * @param request the request; used only if params is not null
     * @param response the response
     * @param uri the redirect uri (not encoded; not including context-path),
     * or null to denote {@link #getOriginFullServlet}
     * It is OK to relevant (without leading '/').
     * If starts with "/", the context path of request is assumed.
     * To reference to foreign context, use "~ctx/" where ctx is the
     * context path of the foreign context (without leading '/').
     * <br/>Notice that, since 3.6.3, <code>uri</code> could contain
     * '*' (to denote locale and browser). Refer to {@link #locate}.
     * @param params the attributes that will be set when the redirection
     * is back; null to ignore; format: (String, Object)
     * @param mode one of {@link #OVERWRITE_URI}, {@link #IGNORE_PARAM},
     * and {@link #APPEND_PARAM}. It defines how to handle if both uri
     * and params contains the same parameter.
     */
    public static final void sendRedirect(ServletContext ctx, HttpServletRequest request, HttpServletResponse response,
            String uri, Map params, int mode) throws IOException, ServletException {
        uri = locate(ctx, request, uri, null);
        final String encodedUrl = encodeRedirectURL(ctx, request, response, uri, params, mode);
        //if (log.isDebugEnabled()) log.debug("redirect to " + encodedUrl);
        response.sendRedirect(encodedUrl);
    }

    /** Encodes an URL such that it can be used with HttpServletResponse.sendRedirect.
     */
    public static final String encodeRedirectURL(ServletContext ctx, HttpServletRequest request,
            HttpServletResponse response, String uri, Map params, int mode) {
        if (uri == null) {
            uri = request.getContextPath() + getOriginFullServlet(request);
        } else {
            final int len = uri.length();
            if (len == 0 || uri.charAt(0) == '/') {
                uri = request.getContextPath() + uri;
            } else if (uri.charAt(0) == '~') {
                final int j = uri.indexOf('/', 1);
                final String ctxroot = j >= 0 ? "/" + uri.substring(1, j) : "/" + uri.substring(1);
                final ExtendletContext extctx = Servlets.getExtendletContext(ctx, ctxroot.substring(1));
                if (extctx != null) {
                    uri = j >= 0 ? uri.substring(j) : "/";
                    return extctx.encodeRedirectURL(request, response, uri, params, mode);
                } else {
                    uri = len >= 2 && uri.charAt(1) == '/' ? uri.substring(1) : '/' + uri.substring(1);
                }
            }
        }

        return response.encodeRedirectURL(generateURI(uri, params, mode));
    }

    /**
     * Converts a date string to a Date instance.
     * The format of the giving date string must be complaint
     * to HTTP protocol.
     *
     * @exception ParseException if the string is not valid
     */
    public static final Date toDate(String sdate) throws ParseException {
        ParseException ex = null;
        for (String df : _dfs) {
            try {
                return new SimpleDateFormat(df, Locale.US).parse(sdate);
            } catch (ParseException t) {
                if (ex == null)
                    ex = t;
            }
        }
        throw ex;
    }

    private static final String PATH_REGEX = "^(/[-\\w:@&?=+,.!/~*'%$_;\\(\\)]*)?$";
    private static final Pattern PATH_PATTERN = Pattern.compile(PATH_REGEX);

    /**
     * Returns the normalized path, or null if it is invalid.
     * It is invalid if it is null, or starts with "/" and contains "..".
     *
     * @since 10.0.0
     */
    public static String normalizePath(String path) {
        if (path == null) {
            return null;
        }
        if ("/".equals(path)) {
            return path;
        }

        List<String> parts = new ArrayList<>();
        String[] segments = path.split("/");

        for (String segment : segments) {
            if (segment.equals("..")) {
                if (!parts.isEmpty()) {
                    parts.remove(parts.size() - 1); // Go up one directory
                } else {
                    // Path is trying to go above the root, which might be a security issue
                    return null;
                }
            } else if (!segment.equals(".") && !segment.isEmpty()) {
                parts.add(segment); // Add non-empty, non-current directory segments
            }
        }

        String result =  String.join("/", parts);
        if (path.startsWith("/")) {
            result = "/" + result;
        }
        if (path.endsWith("/")) {
            result = result + "/";
        }
        return result;
    }

    /**
     * Returns whether the specified path is valid.
     * It is valid if it is null, or starts with "/" and doesn't contain "..".
     *
     * @since 10.0.0
     */
    public static boolean isValidPath(String path) {
        if (path == null)
            return false;
        path = normalizePath(path);

        if (path == null || !PATH_PATTERN.matcher(path).matches()) {
            return false;
        }
        if (path.startsWith("/../") || path.equals("/..")) {
            return false;
        }
        final int slash2Count = countToken("//", path);
        return slash2Count <= 0;
    }

    /**
     * Returns the path of the specified URI, or null if not found.
     * It is the same as new URI(uri).getPath(), except that it returns null
     * if the path is invalid.
     *
     * @since 10.0.0
     * @see #isValidPath(String)
     */
    public static String sanitizePath(String path) {
        if (path == null)
            return null;
        String normalizedPath;
        try {
            normalizedPath = path.startsWith("/")
                    ? normalizePath(path) :
                    new URI(path).normalize().toString();
        } catch (URISyntaxException e) {
            return null;
        }

        if (!isValidPath(normalizedPath))
            return null;

        // Return the sanitized URL path
        return normalizedPath;
    }

    private static int countToken(final String token, final String target) {
        int tokenIndex = 0;
        int count = 0;
        while (tokenIndex != -1) {
            tokenIndex = target.indexOf(token, tokenIndex);
            if (tokenIndex > -1) {
                tokenIndex++;
                count++;
                }
            }
        return count;
    }

    /**
     * Converts a data to a string complaint to HTTP protocol.
     */
    public static final String toString(Date date) {
        return new SimpleDateFormat(_dfs[0], Locale.US).format(date);
    }

    private static final String[] _dfs = { "EEE, dd MMM yyyy HH:mm:ss zzz", "EEEEEE, dd-MMM-yy HH:mm:ss zzz",
            "EEE MMMM d HH:mm:ss yyyy" };

    // it's used to lock the media when writing to response.
    private static final Map<Media, Object> LOCKS = Collections.synchronizedMap(new WeakHashMap<>());

    /** Write the specified media to HTTP response.
     *
     * @param response the HTTP response to write to
     * @param media the content to be written
     * @param download whether to cause the download to show at the client.
     * If true, it sets the Content-Disposition header.
     * @param repeatable whether to use {@link RepeatableInputStream}
     * or {@link RepeatableReader} to read the media.
     * It is better to specify true if the media might be read repeatedly.
     * @since 3.5.0
     */
    public static void write(HttpServletRequest request, HttpServletResponse response, Media media, boolean download,
            boolean repeatable) throws IOException {
        //2012/03/09 TonyQ: ZK-885 Iframe with PDF stop works in IE 8 when we have Accept-Ranges = bytes.

        if (!Servlets.isBrowser(request, "ie")) {
            response.setHeader("Accept-Ranges", "bytes");
        }

        final boolean headOnly = "HEAD".equalsIgnoreCase(request.getMethod());
        final byte[] data;
        int from = -1, to = -1;
        final Object lock;
        synchronized (LOCKS) {
            lock = LOCKS.computeIfAbsent(media, k -> new Object());
        }
        synchronized (lock) { //Bug 1896797: media might be accessed concurrently.
            //reading an image and send it back to client
            final String ctype = media.getContentType();
            if (ctype != null)
                response.setContentType(ctype);

            if (media.isContentDisposition()) {
                String contentDisposition;
                String flnm = "";
                if (download) {
                    contentDisposition = "attachment";

                    // Bug ZK-1257: Filedownload.save(media, filename) does not save the media as the specified filename
                    StringBuffer temp = request.getRequestURL();
                    final String update_uri = (String) request.getSession().getServletContext()
                            .getAttribute("org.zkoss.zk.ui.http.update-uri"); //B65-ZK-1619
                    if (update_uri != null && temp.toString().contains(update_uri + "/view")) {
                        // for Bug ZK-2350, we don't specify the filename when coming with ZK Fileupload, but invoke this directly as Bug ZK-1619
                        //                    final String saveAs = URLDecoder.decode(temp.substring(temp.lastIndexOf("/")+1), "UTF-8");
                        //                    flnm = ("".equals(saveAs)) ? media.getName() : saveAs;
                        // ZK-3058: remove jsessionid if any
                        int jsessionPos = temp.indexOf(";jsessionid=");
                        if (jsessionPos != -1)
                            flnm = URLDecoder.decode(
                                    temp.substring(temp.lastIndexOf("/") + 1, jsessionPos),
                                    "UTF-8"
                            );
                    } else
                        flnm = media.getName();
                } else {
                    contentDisposition = "inline";
                    flnm = media.getName();
                }
                // ZK-3058: filename for legacy browsers, filename* for modern browsers
                if (flnm != null && flnm.length() > 0)
                    contentDisposition += ";filename=" + encodeFilename(request, flnm) + ";filename*=UTF-8''" + encodeRfc3986(flnm);
                response.setHeader("Content-Disposition", contentDisposition);
            }

            final String rs = request.getHeader("Range");
            if (rs != null && rs.length() > 0) {
                final int[] range = parseRange(rs);
                if (range != null) {
                    from = range[0];
                    to = range[1];
                }
            }

            if (!media.inMemory()) {
                final ServletOutputStream out = response.getOutputStream();
                if (media.isBinary()) {
                    InputStream in = media.getStreamData();
                    if (repeatable)
                        in = RepeatableInputStream.getInstance(in);
                    try {
                        if (headOnly) {
                            int cnt = 0;
                            final byte[] buf = new byte[512];
                            for (int v; (v = in.read(buf)) >= 0;)
                                cnt += v;
                            response.setContentLength(cnt);
                            return;
                        }

                        if (from >= 0) { //partial
                            PartialByteStream pbs = new PartialByteStream(from, to);
                            Files.copy(pbs, in);
                            pbs.responseTo(response);
                        } else {
                            Files.copy(out, in);
                        }
                    } catch (IOException ex) {
                        //browser might close the connection
                        //and reread (test case: B30-1896797.zul)
                        //so, read it completely, since 2nd read counts on it
                        if (in instanceof org.zkoss.io.Repeatable) {
                            try {
                                final byte[] buf = new byte[1024 * 8];
                                int v;
                                do {
                                    v = in.read(buf);
                                } while (v >= 0);
                            } catch (Throwable t) { //ignore it
                            }
                        }
                        throw ex;
                    } finally {
                        in.close();
                    }
                } else {
                    final String charset = getCharset(ctype);
                    Reader in = media.getReaderData();
                    if (repeatable)
                        in = RepeatableReader.getInstance(in);
                    try {
                        if (headOnly) {
                            int cnt = 0;
                            final char[] buf = new char[256];
                            for (int v; (v = in.read(buf)) >= 0;)
                                cnt += new String(buf, 0, v).getBytes(charset).length;
                            response.setContentLength(cnt);
                            return;
                        }

                        if (from >= 0) { //partial
                            PartialByteStream pbs = new PartialByteStream(from, to);
                            OutputStreamWriter wt = new OutputStreamWriter(pbs, charset);
                            Files.copy(wt, in);
                            wt.close(); //flush to pbs
                            pbs.responseTo(response);
                        } else {
                            OutputStreamWriter wt = new OutputStreamWriter(out, charset);
                            Files.copy(wt, in);
                            wt.close(); //flush to out
                        }
                    } catch (IOException ex) {
                        //browser might close the connection and reread
                        //so, read it completely, since 2nd read counts on it
                        if (in instanceof org.zkoss.io.Repeatable) {
                            try {
                                final char[] buf = new char[1024 * 4];
                                int v;
                                do {
                                    v = in.read(buf);
                                } while (v >= 0);
                            } catch (Throwable t) { //ignore it
                            }
                        }
                        throw ex;
                    } finally {
                        in.close();
                    }
                }
                out.flush();
                return; //done;
            }

            data = media.isBinary() ? media.getByteData() : media.getStringData().getBytes(getCharset(ctype));
        }

        if (headOnly) {
            response.setContentLength(data.length);
        } else {
            final ServletOutputStream out = response.getOutputStream();
            if (from >= 0) { //partial
                response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);

                int f = from <= data.length ? from : data.length - 1;
                int t = to >= 0 && to < data.length ? to : data.length - 1;
                int cnt = t - f + 1;
                response.setContentLength(cnt);
                response.setHeader("Content-Range", "bytes " + f + "-" + t + "/" + data.length);

                out.write(data, f, cnt);
            } else {
                response.setContentLength(data.length);
                out.write(data);
            }
            out.flush();
        }
    }

    /** Filename can be quoted-string.
     * Refer to http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html
     * and http://www.w3.org/Protocols/rfc2616/rfc2616-sec2.html
    */
    private static String encodeFilename(HttpServletRequest request, String filename) {
        // ZK-2143: should access Chinese filename
        String agent = request.getHeader("USER-AGENT");
        if (agent != null) {
            try {
                if (agent.contains("Trident")) {
                    filename = encodeRfc3986(filename);
                }
            } catch (UnsupportedEncodingException e) {
                // ignore it, if not supported
                log.warn("", e);
            }
        }

        return '"' + Strings.escape(filename, "\"") + '"';
    }

    private static String encodeRfc3986(String data) throws UnsupportedEncodingException {
        return URLEncoder.encode(data, "UTF-8").replaceAll("\\+", "%20");
    }

    private static String getCharset(String contentType) {
        if (contentType != null) {
            int j = contentType.indexOf("charset=");
            if (j >= 0) {
                String cs = contentType.substring(j + 8).trim();
                if (cs.length() > 0)
                    return cs;
            }
        }
        return "UTF-8";
    }

    private static int[] parseRange(String range) {
        range = range.toLowerCase(java.util.Locale.ENGLISH);
        for (int j = 0, k, len = range.length(); (k = range.indexOf("bytes", j)) >= 0;) {
            for (k += 5; k < len;) {
                char cc = range.charAt(k++);
                if (cc == ' ' || cc == '\t')
                    continue;
                if (cc == '=') {
                    j = range.indexOf('-', k);
                    try {
                        int from = Integer.parseInt((j >= 0 ? range.substring(k, j) : range.substring(k)).trim());
                        if (from >= 0) {
                            if (j >= 0) {
                                String s = range.substring(j + 1).trim();
                                if (s.length() > 0) {
                                    int to = Integer.parseInt(s);
                                    if (to >= from)
                                        return new int[] { from, to };
                                }
                            }
                            return new int[] { from, -1 };
                        }
                    } catch (Throwable ex) { //ignore
                    }
                    if (log.isDebugEnabled())
                        log.debug("Failed to parse Range: " + range);
                    return null;
                }
            }
            j = k;
        }
        return null;
    }
}