/**
 * The contents of this file are subject to the license and copyright
 * detailed in the LICENSE and NOTICE files at the root of the source
 * tree and available online at
 *
 * http://www.dspace.org/license/
 */
package org.dspace.app.dav;

import java.io.IOException;
import java.net.URLDecoder;
import java.sql.SQLException;
import java.util.Date;
import java.util.StringTokenizer;

import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.codec.binary.Base64;
import org.apache.log4j.Logger;
import org.dspace.authenticate.AuthenticationManager;
import org.dspace.authenticate.AuthenticationMethod;
import org.dspace.authorize.AuthorizeException;
import org.dspace.core.ConfigurationManager;
import org.dspace.core.Context;
import org.dspace.core.LogManager;
import org.dspace.core.Utils;
import org.dspace.eperson.EPerson;


/**
 * Servlet implementing WebDAV server for DSpace.
 * <P>
 * 
 * @author Larry Stone
 * @version $Revision: 5845 $
 */
public class DAVServlet extends HttpServlet

{
    
    /** log4j category. */
    private static Logger log = Logger.getLogger(DAVServlet.class);

    /** Names of DAV HTTP extension methods. */
    private static final String METHOD_PROPFIND = "PROPFIND";

    /** The Constant METHOD_PROPPATCH. */
    private static final String METHOD_PROPPATCH = "PROPPATCH";

    /** The Constant METHOD_MKCOL. */
    private static final String METHOD_MKCOL = "MKCOL";

    /** The Constant METHOD_COPY. */
    private static final String METHOD_COPY = "COPY";

    /** The Constant METHOD_MOVE. */
    private static final String METHOD_MOVE = "MOVE";

    /** The Constant METHOD_DELETE. */
    private static final String METHOD_DELETE = "DELETE";

    /** Method names of standard HTTP methods; we have to override HttpServlet fields, because they are private (ugh). */
    private static final String METHOD_GET = "GET";

    /** The Constant METHOD_PUT. */
    private static final String METHOD_PUT = "PUT";

    /** The Constant METHOD_OPTIONS. */
    private static final String METHOD_OPTIONS = "OPTIONS";

    /** Switch to allow anonymous (unauthenticated) access to DAV resources. If true, client doesn't have to authenticate, false they do. */
    private static boolean allowAnonymousAccess = ConfigurationManager
            .getBooleanProperty("dav.access.anonymous");

    /** Guess at longest status text the servlet container will tolerate; Tomcat 5.0 handles this, but dies on longer messages. */
    private static final int HTTP_STATUS_MESSAGE_MAX = 1000;

    /** A random secret to embed in cookies, generated fresh at every startup:. */
    private static final String cookieSecret = Utils.generateHexKey();

    // name of our HTTP cookie.
    /** The Constant COOKIE_NAME. */
    private static final String COOKIE_NAME = "DSpaceDavAuth";

    // sell-by time (shelf life) for cookies, in milliseconds: 1/2 hour
    /** The Constant COOKIE_SELL_BY. */
    private static final long COOKIE_SELL_BY = 30 * 60 * 1000;

    // 'C' is for cookie..
    /**
     * Gimme cookie.
     * 
     * @param request the request
     * 
     * @return the cookie
     */
    private static Cookie gimmeCookie(HttpServletRequest request)
    {
        Cookie cookies[] = request.getCookies();
        if (cookies != null)
        {
            for (Cookie element : cookies)
            {
                if (element.getName().equals(COOKIE_NAME))
                {
                    return element;
                }
            }
        }
        return null;
    }

    /**
     * Get Session Cookie.
     * <p>
     * DAVServlet rolls its own session cookie because the Servlet container's
     * session <em>cannot</em> be constrained to use ONLY cookies and NOT
     * URL-rewriting, and the latter would break the DAV protocol so we cannot
     * use it. Since we really only need to cache the authenticated EPerson (an
     * integer ID) anyway, it's easy enough so simply stuff that into a cookie.
     * <p>
     * Cookie format is: <br>
     * {timestamp}!{epersonID}!{client-IP}!{MAC} <br>
     * where timestamp and eperson are integers; client IP is dotted IP
     * notation, and MAC is the hex MD5 of the preceding fields plus the
     * "cookieSecret" string. The MAC ensures that the cookie was issued by this
     * servlet.
     * <p>
     * Look for authentication cookie and try to get a previously-authenticated
     * EPerson from it if found. Also check the timestamp to be sure the cookie
     * isn't "stale".
     * <p>
     * NOTE This is also used by the SOAP servlet.
     * <p>
     * 
     * @param context -
     * set user in this context
     * @param request -
     * HTTP request.
     * 
     * @return true when a fresh cookie yields a valid eperson.
     * 
     * @throws SQLException the SQL exception
     */
    protected static boolean getAuthFromCookie(Context context,
            HttpServletRequest request) throws SQLException
    {
        Cookie cookie = gimmeCookie(request);
        if (cookie == null)
        {
            return false;
        }
        String crumb[] = cookie.getValue().split("\\!");
        if (crumb.length != 4)
        {
            log
                    .warn("Got invalid cookie value = \"" + cookie.getValue()
                            + "\"");
            return false;
        }
        long timestamp = 0;
        int epersonID = 0;
        try
        {
            timestamp = Long.parseLong(crumb[0]);
            epersonID = Integer.parseInt(crumb[1]);
        }
        catch (NumberFormatException e)
        {
            log.warn("Error groveling cookie, " + e.toString());
            return false;
        }

        // check freshness
        long now = new Date().getTime();
        if (timestamp > now || (now - timestamp) > COOKIE_SELL_BY)
        {
            log.warn("Cookie is stale or has weird time, value = \""
                    + cookie.getValue() + "\"");
            return false;
        }

        // check IP address
        if (!crumb[2].equals(request.getRemoteAddr()))
        {
            log.warn("Cookie fails IP Addr test, value = \""
                    + cookie.getValue() + "\"");
            return false;
        }

        // check MAC
        String mac = Utils.getMD5(crumb[0] + "!" + crumb[1] + "!" + crumb[2]
                + "!" + cookieSecret);
        if (!mac.equals(crumb[3]))
        {
            log.warn("Cookie fails MAC test, value = \"" + cookie.getValue()
                    + "\"");
            return false;
        }

        // looks like the browser reguritated a good one:
        EPerson cuser = EPerson.find(context, epersonID);
        if (cuser != null)
        {
            context.setCurrentUser(cuser);
            log.debug("Got authenticated user from cookie, id=" + crumb[1]);
            return true;
        }
        return false;
    }

    /**
     * Set a new cookie -- only bother if there is no existing cookie or it's at
     * least halfway stale, so you're not churning it.. When force is true,
     * always set a fresh cookie. (e.g. after mac failure upon server restart,
     * etc)
     * <p>
     * 
     * @param context -
     * get user from context
     * @param request the request
     * @param response the response
     * @param force the force
     */
    protected static void putAuthCookie(Context context,
            HttpServletRequest request, HttpServletResponse response,
            boolean force)
    {
        Cookie cookie = gimmeCookie(request);
        long now = new Date().getTime();
        if (!force && cookie != null)
        {
            String crumb[] = cookie.getValue().split("\\!");
            if (crumb.length == 4)
            {
                long timestamp = -1;
                try
                {
                    timestamp = Long.parseLong(crumb[0]);
                }
                catch (NumberFormatException e)
                {
                }

                // check freshness - skip setting cookie if old one isn't stale
                if (timestamp > 0 && (now - timestamp) < (COOKIE_SELL_BY / 2))
                {
                    return;
                }
            }
        }
        EPerson user = context.getCurrentUser();
        if (user == null)
        {
            return;
        }
        String value = String.valueOf(now) + "!" + String.valueOf(user.getID())
                + "!" + request.getRemoteAddr() + "!";
        String mac = Utils.getMD5(value + cookieSecret);
        cookie = new Cookie(COOKIE_NAME, value + mac);
        cookie.setPath(request.getContextPath());
        response.addCookie(cookie);

        log.debug("Setting new cookie, value = \"" + value + mac + "\"");
    }

    /**
     * Get authenticated user for this service. Returns null upon failure, with
     * the implication that an error repsonse has already been "sent", so caller
     * should not set anything else in servlet response.
     * 
     * @param request the request
     * @param response the response
     * @param username the username
     * @param password the password
     * 
     * @return the context
     * 
     * @throws IOException Signals that an I/O exception has occurred.
     * @throws SQLException the SQL exception
     */
    private static Context authenticate(HttpServletRequest request,
            HttpServletResponse response, String username, String password)
            throws IOException, SQLException
    {
        Context context = new Context();

        if (getAuthFromCookie(context, request))
        {
            putAuthCookie(context, request, response, false);
            return context;
        }

        // get username/password from Basic auth header if avail:
        String cred = request.getHeader("Authorization");
        if (cred != null && username == null && password == null)
        {
            log.info(LogManager.getHeader(context, "got creds", "Authorize: "
                    + cred));
            StringTokenizer ct = new StringTokenizer(cred);

            // format: Basic {username:password in base64}
            if (ct.nextToken().equalsIgnoreCase("Basic"))
            {
                String crud = ct.nextToken();
                String dcrud = new String(Base64.decodeBase64(crud.getBytes()));
                int colon = dcrud.indexOf(':');
                if (colon > 0)
                {
                    username = decodeFromURL(dcrud.substring(0, colon));
                    password = decodeFromURL(dcrud.substring(colon + 1));
                    log
                            .info(LogManager.getHeader(context, "auth",
                                    "Got username=\"" + username
                                            + "\" out of \"" + crud + "\"."));
                }
            }
        }
        if (AuthenticationManager.authenticate(context, username, password,
                null, request) == AuthenticationMethod.SUCCESS)
        {
            log.info(LogManager.getHeader(context, "auth",
                    "Authentication returned SUCCESS, eperson="
                            + context.getCurrentUser().getEmail()));
        }
        else
        {
            if (username == null)
            {
                log.info(LogManager.getHeader(context, "auth",
                        "No credentials, so sending WWW-Authenticate header."));
            }
            else
            {
                log.warn(LogManager.getHeader(context, "auth",
                        "Authentication FAILED, cred=" + cred));
            }

            // ...EXCEPT if dav.access.anonymous is true in config:
            if (!allowAnonymousAccess)
            {
                if (response != null)
                {
                    response.setHeader("WWW-Authenticate",
                            "Basic realm=\"dspace\"");
                    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
                }
                return null;
            }
        }

        // Set any special groups - invoke the authentication mgr.
        int[] groupIDs = AuthenticationManager.getSpecialGroups(context,
                request);
        for (int element : groupIDs)
        {
            context.setSpecialGroup(element);
            log.debug("Adding Special Group id=" + String.valueOf(element));
        }
        putAuthCookie(context, request, response, true);
        return context;
    }

    /**
     * Return portion of URI path relevant to the DAV resource. We go through
     * the extra pain of chopping up getRequestURI() because it is NOT
     * URL-decoded by the Servlet container, while unfortunately getPathInfo()
     * IS pre-decoded, leaving a redudndant "/" (and who knows what else) in the
     * handle. Since the "handle" may not even be a CNRI Handle, we don't want
     * to assume it even has a "/" (escaped or not).
     * <p>
     * Finally, search for doubled-up '/' separators and coalesce them.
     * 
     * @param request the request
     * 
     * @return String of undecoded path NOT starting with '/'.
     */
    private static String getDavResourcePath(HttpServletRequest request)
    {
        String path = request.getRequestURI();
        String ppath = path.substring(request.getContextPath().length());
        String scriptName = request.getServletPath();
        if (ppath.startsWith(scriptName))
        {
            ppath = ppath.substring(scriptName.length());
        }
        // log.debug("Got DAV URI: BEFORE // FIXUP: PATH_INFO=\"" + ppath+"\"");
        // turn all double '/' ("//") in URI into single '/'
        StringBuffer sb = new StringBuffer(ppath);
        int i = ppath.length() - 2;
        if (i > 0)
        {
            while ((i = ppath.lastIndexOf("//", i)) > -1)
            {
                sb.deleteCharAt(i + 1);
                --i;
            }
        }
        // remove leading '/'
        if (sb.length() > 0 && sb.charAt(0) == '/')
        {
            sb.deleteCharAt(0);
        }
        ppath = sb.toString();
        log.debug("Got DAV URI: PATH_INFO=\"" + ppath + "\"");
        return ppath;
    }

    /**
     * override service() to add DAV methods.
     * 
     * @param request the request
     * @param response the response
     * 
     * @throws ServletException the servlet exception
     * @throws IOException Signals that an I/O exception has occurred.
     */
    @Override
    protected void service(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException
    {
        String method = request.getMethod();

        // no authentication needed for OPTIONS
        if (method.equals(METHOD_OPTIONS))
        {
            doOptions(request, response);
        }
        else if (!serviceInternal(method, request, response))
        {
            super.service(request, response);
        }
    }

    /**
     * truncate string to max length for HTTP status message.
     * 
     * @param msg the msg
     * 
     * @return the string
     */
    private static String truncateForStatus(String msg)
    {
        return (msg.length() > HTTP_STATUS_MESSAGE_MAX) ? msg.substring(0,
                HTTP_STATUS_MESSAGE_MAX)
                + "... [Message truncated, see logs for details.]" : msg;
    }

    /**
     * Pass this request along to the appropriate resource and method. Includes
     * authentication, where needed. Return true if we handle this request,
     * false otherwise. True means response has been "sent", false not.
     * 
     * @param method the method
     * @param request the request
     * @param response the response
     * 
     * @return true, if service internal
     * @throws IOException Signals that an I/O exception has occurred.
     */
    protected static boolean serviceInternal(String method,
            HttpServletRequest request, HttpServletResponse response)
            throws IOException
    {
        // Fake new DAV methods not understood by the Apache Servlet base class
        // (returns HTTP/500 when it sees unrecognised method)
        // The way it is faked is by submitting "delete=true" in the PUT URL's
        // query parameters (for a delete)
        // The way it is faked is by submitting "mkcol=true" in the PUT URL's
        // query parameters (for a mk-collection)
        if (method.equals(METHOD_PUT)
                && request.getQueryString().indexOf("delete=true") >= 0)
        {
            method = METHOD_DELETE;
        }
        if (method.equals(METHOD_PUT)
                && request.getQueryString().indexOf("mkcol=true") >= 0)
        {
            method = METHOD_MKCOL;
        }

        // if not a DAV method (i.e. POST), defer to superclass.
        if (!(method.equals(METHOD_PROPFIND) || method.equals(METHOD_PROPPATCH)
                || method.equals(METHOD_MKCOL) || method.equals(METHOD_COPY)
                || method.equals(METHOD_MOVE) || method.equals(METHOD_DELETE)
                || method.equals(METHOD_GET) || method.equals(METHOD_PUT)))
        {
            return false;
        }

        // set all incoming encoding to UTF-8
        request.setCharacterEncoding("UTF-8");
        String pathElt[] = getDavResourcePath(request).split("/");

        Context context = null;
        try
        {
            // this sends a response on failure, unless it throws.
            context = authenticate(request, response, null, null);
            if (context == null)
            {
                return true;
            }

            // Note: findResource sends error response if it fails.
            DAVResource resource = DAVResource.findResource(context, request,
                    response, pathElt);
            if (resource != null)
            {
                if (method.equals(METHOD_PROPFIND))
                {
                    resource.propfind();
                }
                else if (method.equals(METHOD_PROPPATCH))
                {
                    resource.proppatch();
                }
                else if (method.equals(METHOD_COPY))
                {
                    resource.copy();
                }
                else if (method.equals(METHOD_DELETE))
                {
                    resource.delete();
                }
                else if (method.equals(METHOD_MKCOL))
                {
                    resource.mkcol();
                }
                else if (method.equals(METHOD_GET))
                {
                    resource.get();
                }
                else if (method.equals(METHOD_PUT))
                {
                    resource.put();
                }
                else
                {
                    response.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED);
                }
                context.complete();
                context = null;
            }
        }
        catch (SQLException e)
        {
            log.error(e.toString(),e);
            response
                    .sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
                            truncateForStatus("Database access error: "
                                    + e.toString()));
        }
        catch (AuthorizeException e)
        {
            if(log.isDebugEnabled())
            {
                log.debug(e.toString(),e);
            }
            else
            {
                log.info(e.toString());
            }
            response.sendError(HttpServletResponse.SC_FORBIDDEN,
                    truncateForStatus("Access denied: " + e.toString()));
        }
        catch (DAVStatusException e)
        {
            log.error(e.toString(),e);
            response.sendError(e.getStatus(), truncateForStatus(e
                    .getMessage()));
        }
        catch (IOException e)
        {
            log.error(e.toString(),e);
            response
                    .sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
                            truncateForStatus("IO Error: "
                                    + e.toString()));
        }
        catch (Exception e)
        {
            log.error(e.toString(),e);
            response
                    .sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
                            truncateForStatus("IO Error: "
                                    + e.toString()));
        }
        finally
        {
            // Abort the context if it's still valid
            if (context != null && context.isValid())
            {
                context.abort();
            }
        }
        return true;
    }

    /**
     * Handler for HTTP OPTIONS method. Same for all resources under the WeDAV
     * root. Add DAV methods so client knows we handle DAV.
     * 
     * @param request the request
     * @param response the response
     * 
     * @throws ServletException the servlet exception
     * @throws IOException Signals that an I/O exception has occurred.
     */
    @Override
    protected void doOptions(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException
    {
        // we only support minimal DAV
        response.addHeader("DAV", "1");

        response.addHeader("Allow",
                "GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, "
                        + "PROPFIND, PROPPATCH, MKCOL, COPY, MOVE");
    }

    /**
     * Sugar-coating for URLDecoder.decode, used all over.
     * 
     * @param in the in
     * 
     * @return the string
     */
    protected static String decodeFromURL(String in)
    {
        try
        {
            return URLDecoder.decode(in, "UTF-8");
        }
        catch (java.io.UnsupportedEncodingException e)
        {
            return "";
        }
    }
}
