/*
    Licensed to the Apache Software Foundation (ASF) under one
    or more contributor license agreements.  See the NOTICE file
    distributed with this work for additional information
    regarding copyright ownership.  The ASF licenses this file
    to you under the Apache License, Version 2.0 (the
    "License"); you may not use this file except in compliance
    with the License.  You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

    Unless required by applicable law or agreed to in writing,
    software distributed under the License is distributed on an
    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
    KIND, either express or implied.  See the License for the
    specific language governing permissions and limitations
    under the License.
 */
package org.apache.wiki.attachment;

import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.FileItemFactory;
import org.apache.commons.fileupload.FileUploadException;
import org.apache.commons.fileupload.ProgressListener;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.wiki.api.core.Attachment;
import org.apache.wiki.api.core.Context;
import org.apache.wiki.api.core.ContextEnum;
import org.apache.wiki.api.core.Engine;
import org.apache.wiki.api.core.Page;
import org.apache.wiki.api.core.Session;
import org.apache.wiki.api.exceptions.ProviderException;
import org.apache.wiki.api.exceptions.RedirectException;
import org.apache.wiki.api.exceptions.WikiException;
import org.apache.wiki.api.providers.WikiProvider;
import org.apache.wiki.api.spi.Wiki;
import org.apache.wiki.auth.AuthorizationManager;
import org.apache.wiki.auth.permissions.PermissionFactory;
import org.apache.wiki.i18n.InternationalizationManager;
import org.apache.wiki.preferences.Preferences;
import org.apache.wiki.ui.progress.ProgressItem;
import org.apache.wiki.ui.progress.ProgressManager;
import org.apache.wiki.util.HttpUtil;
import org.apache.wiki.util.TextUtil;

import javax.servlet.ServletConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
import java.security.Permission;
import java.security.Principal;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;


/**
 *  This is the chief JSPWiki attachment management servlet.  It is used for
 *  both uploading new content and downloading old content.  It can handle
 *  most common cases, e.g. check for modifications and return 304's as necessary.
 *  <p>
 *  Authentication is done using JSPWiki's normal AAA framework.
 *  <p>
 *  This servlet is also capable of managing dynamically created attachments.
 *
 *
 *  @since 1.9.45.
 */
public class AttachmentServlet extends HttpServlet {

    private static final long serialVersionUID = 3257282552187531320L;
    private static final int BUFFER_SIZE = 8192;

    private Engine m_engine;
    private static final Logger LOG = LogManager.getLogger( AttachmentServlet.class );
    private static final String HDR_VERSION = "version";

    /** The maximum size that an attachment can be. */
    private int m_maxSize = Integer.MAX_VALUE;

    /** List of attachment types which are allowed */
    private String[] m_allowedPatterns;
    private String[] m_forbiddenPatterns;

    //
    // Not static as DateFormat objects are not thread safe.
    // Used to handle the RFC date format = Sat, 13 Apr 2002 13:23:01 GMT
    //
    //private final DateFormat rfcDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z");

    /**
     *  Initializes the servlet from Engine properties.
     */
    @Override
    public void init( final ServletConfig config ) throws ServletException {
        m_engine = Wiki.engine().find( config );
        final Properties props = m_engine.getWikiProperties();
        final String tmpDir = m_engine.getWorkDir() + File.separator + "attach-tmp";
        final String allowed = TextUtil.getStringProperty( props, AttachmentManager.PROP_ALLOWEDEXTENSIONS, null );
        m_maxSize = TextUtil.getIntegerProperty( props, AttachmentManager.PROP_MAXSIZE, Integer.MAX_VALUE );

        if( allowed != null && !allowed.isEmpty() ) {
            m_allowedPatterns = allowed.toLowerCase().split( "\\s" );
        } else {
            m_allowedPatterns = new String[ 0 ];
        }

        final String forbidden = TextUtil.getStringProperty( props, AttachmentManager.PROP_FORBIDDENEXTENSIONS,null );
        if( forbidden != null && !forbidden.isEmpty() ) {
            m_forbiddenPatterns = forbidden.toLowerCase().split("\\s");
        } else {
            m_forbiddenPatterns = new String[0];
        }

        final File f = new File( tmpDir );
        if( !f.exists() ) {
            f.mkdirs();
        } else if( !f.isDirectory() ) {
            LOG.fatal( "A file already exists where the temporary dir is supposed to be: {}. Please remove it.", tmpDir );
        }

        LOG.debug( "UploadServlet initialized. Using {} for temporary storage.", tmpDir );
    }

    private boolean isTypeAllowed( String name )
    {
        if( name == null || name.isEmpty() ) return false;

        name = name.toLowerCase();

        for( final String m_forbiddenPattern : m_forbiddenPatterns ) {
            if( name.endsWith( m_forbiddenPattern ) && !m_forbiddenPattern.isEmpty() )
                return false;
        }

        for( final String m_allowedPattern : m_allowedPatterns ) {
            if( name.endsWith( m_allowedPattern ) && !m_allowedPattern.isEmpty() )
                return true;
        }

        return m_allowedPatterns.length == 0;
    }

    /**
     *  Implements the OPTIONS method.
     *
     *  @param req The servlet request
     *  @param res The servlet response
     */

    @Override
    protected void doOptions( final HttpServletRequest req, final HttpServletResponse res ) {
        res.setHeader( "Allow", "GET, PUT, POST, OPTIONS, PROPFIND, PROPPATCH, MOVE, COPY, DELETE");
        res.setStatus( HttpServletResponse.SC_OK );
    }

    /**
     *  Serves a GET with two parameters: 'wikiname' specifying the wikiname
     *  of the attachment, 'version' specifying the version indicator.
     *
     */
    // FIXME: Messages would need to be localized somehow.
    @Override
    public void doGet( final HttpServletRequest  req, final HttpServletResponse res ) throws IOException {
        final Context context = Wiki.context().create( m_engine, req, ContextEnum.PAGE_ATTACH.getRequestContext() );
        final AttachmentManager mgr = m_engine.getManager( AttachmentManager.class );
        final AuthorizationManager authmgr = m_engine.getManager( AuthorizationManager.class );
        final String version = req.getParameter( HDR_VERSION );
        final String nextPage = req.getParameter( "nextpage" );
        final String page = context.getPage().getName();
        int ver = WikiProvider.LATEST_VERSION;

        if( page == null ) {
            LOG.info( "Invalid attachment name." );
            res.sendError( HttpServletResponse.SC_BAD_REQUEST );
            return;
        }

        try( final OutputStream out = res.getOutputStream() ) {
            LOG.debug("Attempting to download att "+page+", version "+version);
            if( version != null ) {
                ver = Integer.parseInt( version );
            }

            final Attachment att = mgr.getAttachmentInfo( page, ver );
            if( att != null ) {
                //
                //  Check if the user has permission for this attachment
                //

                final Permission permission = PermissionFactory.getPagePermission( att, "view" );
                if( !authmgr.checkPermission( context.getWikiSession(), permission ) ) {
                    LOG.debug("User does not have permission for this");
                    res.sendError( HttpServletResponse.SC_FORBIDDEN );
                    return;
                }

                //
                //  Check if the client already has a version of this attachment.
                //
                if( HttpUtil.checkFor304( req, att.getName(), att.getLastModified() ) ) {
                    LOG.debug( "Client has latest version already, sending 304..." );
                    res.sendError( HttpServletResponse.SC_NOT_MODIFIED );
                    return;
                }

                final String mimetype = getMimeType( context, att.getFileName() );
                res.setContentType( mimetype );

                //
                //  We use 'inline' instead of 'attachment' so that user agents
                //  can try to automatically open the file.
                //
                res.addHeader( "Content-Disposition", "inline; filename=\"" + att.getFileName() + "\";" );
                res.addDateHeader("Last-Modified",att.getLastModified().getTime());

                if( !att.isCacheable() ) {
                    res.addHeader( "Pragma", "no-cache" );
                    res.addHeader( "Cache-control", "no-cache" );
                }

                // If a size is provided by the provider, report it.
                if( att.getSize() >= 0 ) {
                    // LOG.info("size:"+att.getSize());
                    res.setContentLength( (int)att.getSize() );
                }

                try( final InputStream  in = mgr.getAttachmentStream( context, att ) ) {
                    int read;
                    final byte[] buffer = new byte[ BUFFER_SIZE ];

                    while( ( read = in.read( buffer ) ) > -1 ) {
                        out.write( buffer, 0, read );
                    }
                }
                LOG.debug( "Attachment {} sent to {} on {}", att.getFileName(), req.getRemoteUser(), HttpUtil.getRemoteAddress(req) );
                if( nextPage != null ) {
                    res.sendRedirect(
                        validateNextPage(
                            TextUtil.urlEncodeUTF8(nextPage),
                            m_engine.getURL( ContextEnum.WIKI_ERROR.getRequestContext(), "", null )
                        )
                    );
                }

            } else {
                final String msg = "Attachment '" + page + "', version " + ver + " does not exist.";
                LOG.info( msg );
                res.sendError( HttpServletResponse.SC_NOT_FOUND, msg );
            }
        } catch( final ProviderException pe ) {
            LOG.debug("Provider failed while reading", pe);
            //
            //  This might fail, if the response is already committed.  So in that
            //  case we just log it.
            //
            sendError( res, "Provider error: "+ pe.getMessage() );
        } catch( final NumberFormatException nfe ) {
            LOG.warn( "Invalid version number: " + version );
            res.sendError( HttpServletResponse.SC_BAD_REQUEST, "Invalid version number" );
        } catch( final SocketException se ) {
            //
            //  These are very common in download situations due to aggressive
            //  clients.  No need to try and send an error.
            //
            LOG.debug( "I/O exception during download", se );
        } catch( final IOException ioe ) {
            //
            //  Client dropped the connection or something else happened.
            //  We don't know where the error came from, so we'll at least
            //  try to send an error and catch it quietly if it doesn't quite work.
            //
            LOG.debug( "I/O exception during download", ioe );
            sendError( res, "Error: " + ioe.getMessage() );
        }
    }

    void sendError( final HttpServletResponse res, final String message ) throws IOException {
        try {
            res.sendError( HttpServletResponse.SC_INTERNAL_SERVER_ERROR, message );
        } catch( final IllegalStateException e ) {
            // ignore
        }
    }

    /**
     *  Returns the mime type for this particular file.  Case does not matter.
     *
     * @param ctx WikiContext; required to access the ServletContext of the request.
     * @param fileName The name to check for.
     * @return A valid mime type, or application/binary, if not recognized
     */
    private static String getMimeType( final Context ctx, final String fileName ) {
        String mimetype = null;

        final HttpServletRequest req = ctx.getHttpRequest();
        if( req != null ) {
            final ServletContext s = req.getSession().getServletContext();

            if( s != null ) {
                mimetype = s.getMimeType( fileName.toLowerCase() );
            }
        }

        if( mimetype == null ) {
            mimetype = "application/binary";
        }

        return mimetype;
    }


    /**
     * Grabs mime/multipart data and stores it into the temporary area.
     * Uses other parameters to determine which name to store as.
     *
     * <p>The input to this servlet is generated by an HTML FORM with
     * two parts. The first, named 'page', is the WikiName identifier
     * for the parent file. The second, named 'content', is the binary
     * content of the file.
     *
     */
    @Override
    public void doPost( final HttpServletRequest req, final HttpServletResponse res ) throws IOException {
        try {
            final String nextPage = upload( req );
            req.getSession().removeAttribute("msg");
            res.sendRedirect( nextPage );
        } catch( final RedirectException e ) {
            final Session session = Wiki.session().find( m_engine, req );
            session.addMessage( e.getMessage() );

            req.getSession().setAttribute("msg", e.getMessage());
            res.sendRedirect( e.getRedirect() );
        }
    }

    /**
     *  Validates the next page to be on the same server as this webapp.
     *  Fixes [JSPWIKI-46].
     */
    private String validateNextPage( String nextPage, final String errorPage ) {
        if( nextPage.contains( "://" ) ) {
            // It's an absolute link, so unless it starts with our address, we'll log an error.
            if( !nextPage.startsWith( m_engine.getBaseURL() ) ) {
                LOG.warn("Detected phishing attempt by redirecting to an unsecure location: "+nextPage);
                nextPage = errorPage;
            }
        }

        return nextPage;
    }

    /**
     *  Uploads a specific mime multipart input set, intercepts exceptions.
     *
     *  @param req The servlet request
     *  @return The page to which we should go next.
     *  @throws RedirectException If there's an error and a redirection is needed
     *  @throws IOException If upload fails
     */
    protected String upload( final HttpServletRequest req ) throws RedirectException, IOException {
        final String msg;
        final String attName = "(unknown)";
        final String errorPage = m_engine.getURL( ContextEnum.WIKI_ERROR.getRequestContext(), "", null ); // If something bad happened, Upload should be able to take care of most stuff
        String nextPage = errorPage;
        final String progressId = req.getParameter( "progressid" );

        // Check that we have a file upload request
        if( !ServletFileUpload.isMultipartContent(req) ) {
            throw new RedirectException( "Not a file upload", errorPage );
        }

        try {
            final FileItemFactory factory = new DiskFileItemFactory();

            // Create the context _before_ Multipart operations, otherwise strict servlet containers may fail when setting encoding.
            final Context context = Wiki.context().create( m_engine, req, ContextEnum.PAGE_ATTACH.getRequestContext() );
            final UploadListener pl = new UploadListener();

            m_engine.getManager( ProgressManager.class ).startProgress( pl, progressId );

            final ServletFileUpload upload = new ServletFileUpload( factory );
            upload.setHeaderEncoding( StandardCharsets.UTF_8.name() );
            if( !context.hasAdminPermissions() ) {
                upload.setFileSizeMax( m_maxSize );
            }
            upload.setProgressListener( pl );
            final List<FileItem> items = upload.parseRequest( req );

            String   wikipage   = null;
            String   changeNote = null;
            //FileItem actualFile = null;
            final List<FileItem> fileItems = new ArrayList<>();

            for( final FileItem item : items ) {
                if( item.isFormField() ) {
                    switch( item.getFieldName() ) {
                    case "page":
                        // FIXME: Kludge alert.  We must end up with the parent page name, if this is an upload of a new revision
                        wikipage = item.getString( StandardCharsets.UTF_8.name() );
                        final int x = wikipage.indexOf( "/" );
                        if( x != -1 ) {
                            wikipage = wikipage.substring( 0, x );
                        }
                        break;
                    case "changenote":
                        changeNote = item.getString( StandardCharsets.UTF_8.name() );
                        if( changeNote != null ) {
                            changeNote = TextUtil.replaceEntities( changeNote );
                        }
                        break;
                    case "nextpage":
                        nextPage = validateNextPage( item.getString( StandardCharsets.UTF_8.name() ), errorPage );
                        break;
                    }
                } else {
                    fileItems.add( item );
                }
            }

            if( fileItems.size() == 0 ) {
                throw new RedirectException( "Broken file upload", errorPage );

            } else {
                for( final FileItem actualFile : fileItems ) {
                    final String filename = actualFile.getName();
                    final long   fileSize = actualFile.getSize();
                    try( final InputStream in  = actualFile.getInputStream() ) {
                        executeUpload( context, in, filename, nextPage, wikipage, changeNote, fileSize );
                    }
                }
            }

        } catch( final ProviderException e ) {
            msg = "Upload failed because the provider failed: "+e.getMessage();
            LOG.warn( msg + " (attachment: " + attName + ")", e );

            throw new IOException( msg );
        } catch( final IOException e ) {
            // Show the submit page again, but with a bit more intimidating output.
            msg = "Upload failure: " + e.getMessage();
            LOG.warn( msg + " (attachment: " + attName + ")", e );

            throw e;
        } catch( final FileUploadException e ) {
            // Show the submit page again, but with a bit more intimidating output.
            msg = "Upload failure: " + e.getMessage();
            LOG.warn( msg + " (attachment: " + attName + ")", e );

            throw new IOException( msg, e );
        } finally {
            m_engine.getManager( ProgressManager.class ).stopProgress( progressId );
            // FIXME: In case of exceptions should absolutely remove the uploaded file.
        }

        return nextPage;
    }

    /**
     *
     * @param context the wiki context
     * @param data the input stream data
     * @param filename the name of the file to upload
     * @param errorPage the place to which you want to get a redirection
     * @param parentPage the page to which the file should be attached
     * @param changenote The change note
     * @param contentLength The content length
     * @return <code>true</code> if upload results in the creation of a new page;
     * <code>false</code> otherwise
     * @throws RedirectException If the content needs to be redirected
     * @throws IOException       If there is a problem in the upload.
     * @throws ProviderException If there is a problem in the backend.
     */
    protected boolean executeUpload( final Context context, final InputStream data,
                                     String filename, final String errorPage,
                                     final String parentPage, final String changenote,
                                     final long contentLength )
            throws RedirectException, IOException, ProviderException {
        boolean created = false;

        try {
            filename = AttachmentManager.validateFileName( filename );
        } catch( final WikiException e ) {
            // this is a kludge, the exception that is caught here contains the i18n key
            // here we have the context available, so we can internationalize it properly :
            throw new RedirectException (Preferences.getBundle( context, InternationalizationManager.CORE_BUNDLE )
                    .getString( e.getMessage() ), errorPage );
        }

        //
        //  FIXME: This has the unfortunate side effect that it will receive the
        //  contents.  But we can't figure out the page to redirect to
        //  before we receive the file, due to the stupid constructor of MultipartRequest.
        //

        if( !context.hasAdminPermissions() ) {
            if( contentLength > m_maxSize ) {
                // FIXME: Does not delete the received files.
                throw new RedirectException( "File exceeds maximum size ("+m_maxSize+" bytes)", errorPage );
            }

            if( !isTypeAllowed(filename) ) {
                throw new RedirectException( "Files of this type may not be uploaded to this wiki", errorPage );
            }
        }

        final Principal user    = context.getCurrentUser();
        final AttachmentManager mgr = m_engine.getManager( AttachmentManager.class );

        LOG.debug("file="+filename);

        if( data == null ) {
            LOG.error("File could not be opened.");
            throw new RedirectException("File could not be opened.", errorPage);
        }

        //  Check whether we already have this kind of page. If the "page" parameter already defines an attachment
        //  name for an update, then we just use that file. Otherwise, we create a new attachment, and use the
        //  filename given.  Incidentally, this will also mean that if the user uploads a file with the exact
        //  same name than some other previous attachment, then that attachment gains a new version.
        Attachment att = mgr.getAttachmentInfo( context.getPage().getName() );
        if( att == null ) {
            att = new org.apache.wiki.attachment.Attachment( m_engine, parentPage, filename );
            created = true;
        }
        att.setSize( contentLength );

        //  Check if we're allowed to do this?
        final Permission permission = PermissionFactory.getPagePermission( att, "upload" );
        if( m_engine.getManager( AuthorizationManager.class ).checkPermission( context.getWikiSession(), permission ) ) {
            if( user != null ) {
                att.setAuthor( user.getName() );
            }

            if( changenote != null && !changenote.isEmpty() ) {
                att.setAttribute( Page.CHANGENOTE, changenote );
            }

            try {
                m_engine.getManager( AttachmentManager.class ).storeAttachment( att, data );
            } catch( final ProviderException pe ) {
                // this is a kludge, the exception that is caught here contains the i18n key
                // here we have the context available, so we can internationalize it properly :
                throw new ProviderException( Preferences.getBundle( context, InternationalizationManager.CORE_BUNDLE ).getString( pe.getMessage() ) );
            }

            LOG.info( "User " + user + " uploaded attachment to " + parentPage + " called "+filename+", size " + att.getSize() );
        } else {
            throw new RedirectException( "No permission to upload a file", errorPage );
        }

        return created;
    }

    /**
     *  Provides tracking for upload progress.
     *
     */
    private static class UploadListener extends ProgressItem implements ProgressListener {
        public long m_currentBytes;
        public long m_totalBytes;

        @Override
        public void update( final long recvdBytes, final long totalBytes, final int item) {
            m_currentBytes = recvdBytes;
            m_totalBytes   = totalBytes;
        }

        @Override
        public int getProgress() {
            return ( int )( ( ( float )m_currentBytes / m_totalBytes ) * 100 + 0.5 );
        }
    }

}


