WaarpFtp4jClient.java

/*
 * This file is part of Waarp Project (named also Waarp or GG).
 *
 *  Copyright (c) 2019, Waarp SAS, and individual contributors by the @author
 *  tags. See the COPYRIGHT.txt in the distribution for a full listing of
 * individual contributors.
 *
 *  All Waarp Project is free software: you can redistribute it and/or
 * modify it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or (at your
 * option) any later version.
 *
 * Waarp is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License along with
 * Waarp . If not, see <http://www.gnu.org/licenses/>.
 */
package org.waarp.ftp.client;

import it.sauronsoftware.ftp4j.FTPAbortedException;
import it.sauronsoftware.ftp4j.FTPClient;
import it.sauronsoftware.ftp4j.FTPCommunicationListener;
import it.sauronsoftware.ftp4j.FTPConnector;
import it.sauronsoftware.ftp4j.FTPDataTransferException;
import it.sauronsoftware.ftp4j.FTPException;
import it.sauronsoftware.ftp4j.FTPFile;
import it.sauronsoftware.ftp4j.FTPIllegalReplyException;
import it.sauronsoftware.ftp4j.FTPListParseException;
import it.sauronsoftware.ftp4j.FTPReply;
import org.waarp.common.file.FileUtils;
import org.waarp.common.logging.SysErrLogger;
import org.waarp.common.logging.WaarpLogger;
import org.waarp.common.logging.WaarpLoggerFactory;
import org.waarp.common.utility.SystemPropertyUtil;

import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.SocketException;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;

import static org.waarp.common.digest.WaarpBC.*;

/**
 * FTP client using FTP4J model (working in all modes)
 */
public class WaarpFtp4jClient implements WaarpFtpClientInterface {
  /**
   * Internal Logger
   */
  private static final WaarpLogger logger =
      WaarpLoggerFactory.getLogger(WaarpFtp4jClient.class);
  private static final int DEFAULT_WAIT = 1;

  static {
    initializedTlsContext();
  }

  final String server;
  int port = 21;
  final String user;
  final String pwd;
  final String acct;
  final int timeout;
  final int keepalive;
  boolean isPassive;
  final int ssl; // -1 native, 1 auth
  protected FTPClient ftpClient;
  protected String result;
  protected String directory = null;

  /**
   * @param server
   * @param port
   * @param user
   * @param pwd
   * @param acct
   * @param isPassive
   * @param ssl -1 native, 1 auth
   * @param timeout
   */
  public WaarpFtp4jClient(final String server, final int port,
                          final String user, final String pwd,
                          final String acct, final boolean isPassive,
                          final int ssl, final int keepalive,
                          final int timeout) {
    this.server = server;
    this.port = port;
    this.user = user;
    this.pwd = pwd;
    this.acct = acct;
    this.isPassive = isPassive;
    this.ssl = ssl;
    this.keepalive = keepalive;
    this.timeout = timeout;
    ftpClient = new FTPClient();
    if (this.ssl != 0) {
      // implicit or explicit
      final TrustManager[] trustManager = {
          new X509TrustManager() {
            @Override
            public X509Certificate[] getAcceptedIssuers() {
              return null;
            }

            @Override
            public final void checkClientTrusted(final X509Certificate[] certs,
                                                 //NOSONAR
                                                 final String authType) {
              // nothing
            }

            @Override
            public final void checkServerTrusted(final X509Certificate[] certs,
//NOSONAR
                                                 final String authType) {
              // nothing
            }
          }
      };
      final SSLContext sslContext;
      try {
        sslContext = getInstanceJDK();
        sslContext.init(null, trustManager, getSecureRandom());
      } catch (final NoSuchAlgorithmException e) {
        throw new IllegalArgumentException("Bad algorithm", e);
      } catch (final KeyManagementException e) {
        throw new IllegalArgumentException("Bad KeyManagement", e);
      } catch (final Exception e) {
        throw new IllegalArgumentException("Bad Provider", e);
      }
      final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
      ftpClient.setSSLSocketFactory(sslSocketFactory);
      if (this.ssl < 0) {
        ftpClient.setSecurity(FTPClient.SECURITY_FTPS);
      } else {
        ftpClient.setSecurity(FTPClient.SECURITY_FTPES);
      }
    } else {
      ftpClient = new FTPClient();
    }
    if (timeout > 0) {
      System.setProperty("ftp4j.activeDataTransfer.acceptTimeout",
                         String.valueOf(timeout));
    }
    System.setProperty("ftp4j.activeDataTransfer.hostAddress", "127.0.0.1");

    ftpClient.addCommunicationListener(new FTPCommunicationListener() {
      @Override
      public final void sent(final String arg0) {
        logger.debug("Command: {}", arg0);
      }

      @Override
      public final void received(final String arg0) {
        logger.debug("Answer: {}", arg0);
      }
    });
    final FTPConnector connector = ftpClient.getConnector();
    int timeoutDefault = timeout > 0? timeout / 1000 : 30;
    connector.setCloseTimeout(timeoutDefault);
    connector.setReadTimeout(timeoutDefault);
    connector.setConnectionTimeout(timeoutDefault);
    connector.setUseSuggestedAddressForDataConnections(true);
  }

  @Override
  public void setReportActiveExternalIPAddress(final String ipAddress) {
    if (ipAddress != null) {
      SystemPropertyUtil.set("ftp4j.activeDataTransfer.hostAddress", ipAddress);
    } else {
      SystemPropertyUtil.clear("ftp4j.activeDataTransfer.hostAddress");
    }
  }

  @Override
  public void setActiveDataTransferPortRange(final int from, final int to) {
    if (from <= 0 || to <= 0) {
      SystemPropertyUtil.clear("ftp4j.activeDataTransfer.portRange");
    } else {
      SystemPropertyUtil.set("ftp4j.activeDataTransfer.portRange",
                             from + "-" + to);
    }
  }

  @Override
  public final String getResult() {
    return result;
  }

  private void reconnect() {
    ftpClient.setAutoNoopTimeout(0);
    try {
      ftpClient.logout();
    } catch (final Exception e) {
      // do nothing
    } finally {
      disconnect();
    }
    waitAfterDataCommand();
    connect();
    if (directory != null) {
      changeDir(directory);
    }
  }

  @Override
  public final boolean connect() {
    result = null;
    boolean isActive = false;
    try {
      waitAfterDataCommand();
      Exception lastExcemption = null;
      for (int i = 0; i < 5; i++) {
        try {
          ftpClient.connect(server, port);
          lastExcemption = null;
          break;
        } catch (final SocketException e) {
          result = CONNECTION_IN_ERROR;
          lastExcemption = e;
        } catch (final Exception e) {
          result = CONNECTION_IN_ERROR;
          lastExcemption = e;
        }
        waitAfterDataCommand();
      }
      if (lastExcemption != null) {
        logger.error(result + ": {}", lastExcemption.getMessage());
        return false;
      }
      try {
        if (acct == null) {
          // no account
          ftpClient.login(user, pwd);
        } else {
          ftpClient.login(user, pwd, acct);
        }
      } catch (final Exception e) {
        logout();
        result = LOGIN_IN_ERROR;
        logger.error(result);
        return false;
      }
      try {
        ftpClient.setType(FTPClient.TYPE_BINARY);
      } catch (final IllegalArgumentException e1) {
        result = SET_BINARY_IN_ERROR;
        logger.error(result + ": {}", e1.getMessage());
        return false;
      }
      changeMode(isPassive);
      if (keepalive > 0) {
        ftpClient.setAutoNoopTimeout(keepalive);
      }
      isActive = true;
      return true;
    } finally {
      if (!isActive && !ftpClient.isPassive()) {
        disconnect();
      }
    }
  }

  @Override
  public final void logout() {
    result = null;
    ftpClient.setAutoNoopTimeout(0);
    logger.debug("QUIT");
    if (executeCommand("QUIT") == null) {
      try {
        ftpClient.logout();
      } catch (final Exception e) {
        // do nothing
      } finally {
        disconnect();
      }
    }
  }

  @Override
  public final void disconnect() {
    result = null;
    ftpClient.setAutoNoopTimeout(0);
    try {
      ftpClient.disconnect(false);
    } catch (final Exception e) {
      logger.debug(DISCONNECTION_IN_ERROR, e);
    }
  }

  @Override
  public final boolean makeDir(final String newDir) {
    result = null;
    try {
      ftpClient.createDirectory(newDir);
      return true;
    } catch (final Exception e) {
      try {
        reconnect();
        ftpClient.createDirectory(newDir);
        return true;
      } catch (final Exception e1) {
        SysErrLogger.FAKE_LOGGER.ignoreLog(e1);
      }
      result = MKDIR_IN_ERROR;
      logger.error(result + ": {}", e.getMessage());
      waitAfterDataCommand();
      return false;
    }
  }

  @Override
  public final boolean changeDir(final String newDir) {
    result = null;
    try {
      directory = newDir;
      ftpClient.changeDirectory(newDir);
      return true;
    } catch (final IOException e) {
      try {
        reconnect();
        ftpClient.changeDirectory(newDir);
        return true;
      } catch (final Exception e1) {
        SysErrLogger.FAKE_LOGGER.ignoreLog(e1);
      }
      result = CHDIR_IN_ERROR;
      logger.error(result + ": {}", e.getMessage());
      return false;
    } catch (final Exception e) {
      try {
        reconnect();
        ftpClient.changeDirectory(newDir);
        return true;
      } catch (final Exception e1) {
        SysErrLogger.FAKE_LOGGER.ignoreLog(e1);
      }
      result = CHDIR_IN_ERROR;
      logger.error(result + ": {}", e.getMessage());
      return false;
    }
  }

  @Override
  public final boolean changeFileType(final boolean binaryTransfer) {
    result = null;
    try {
      if (binaryTransfer) {
        ftpClient.setType(FTPClient.TYPE_BINARY);
      } else {
        ftpClient.setType(FTPClient.TYPE_TEXTUAL);
      }
      return true;
    } catch (final IllegalArgumentException e) {
      result = FILE_TYPE_IN_ERROR;
      logger.error(result + ": {}", e.getMessage());
      return false;
    }
  }

  @Override
  public final void changeMode(final boolean passive) {
    result = null;
    isPassive = passive;
    ftpClient.setPassive(passive);
    waitAfterDataCommand();
  }

  @Override
  public void compressionMode(final boolean compression) {
    if (compression) {
      if (ftpClient.isCompressionSupported()) {
        try {
          ftpClient.setType(FTPClient.TYPE_BINARY);
        } catch (final IllegalArgumentException e1) {
          result = SET_BINARY_IN_ERROR;
          logger.error(result + ": {}", e1.getMessage());
        }
        ftpClient.setCompressionEnabled(true);
      } else {
        logger.warn("Z Compression not supported by Server");
        ftpClient.setCompressionEnabled(false);
      }
    } else {
      ftpClient.setCompressionEnabled(false);
    }
  }

  @Override
  public final boolean store(final String local, final String remote) {
    return transferFile(local, remote, 1);
  }

  @Override
  public final boolean store(final InputStream local, final String remote) {
    return transferFile(local, remote, 1);
  }

  @Override
  public final boolean append(final String local, final String remote) {
    return transferFile(local, remote, 2);
  }

  @Override
  public final boolean append(final InputStream local, final String remote) {
    return transferFile(local, remote, 2);
  }

  @Override
  public final boolean retrieve(final String local, final String remote) {
    return transferFile(local, remote, -1);
  }

  @Override
  public final boolean retrieve(final OutputStream local, final String remote) {
    return transferFile(local, remote);
  }

  @Override
  public final boolean transferFile(final String local, final String remote,
                                    final int getStoreOrAppend) {
    result = null;
    try {
      if (!internalTransferFile(local, remote, getStoreOrAppend)) {
        reconnect();
        return internalTransferFile(local, remote, getStoreOrAppend);
      }
      return true;
    } catch (final IOException e) {
      try {
        reconnect();
        return internalTransferFile(local, remote, getStoreOrAppend);
      } catch (final Exception e1) {
        SysErrLogger.FAKE_LOGGER.ignoreLog(e1);
      }
      result = CANNOT_FINALIZE_TRANSFER_OPERATION;
      logger.error(result + ": {}", e.getMessage());
      return false;
    }
  }

  private boolean internalTransferFile(final String local, final String remote,
                                       final int getStoreOrAppend)
      throws FileNotFoundException {
    if (getStoreOrAppend > 0) {
      final File from = new File(local);
      logger.debug("Will STOR: {}", from);
      FileInputStream stream = null;
      try {
        stream = new FileInputStream(local);
        return transferFile(stream, remote, getStoreOrAppend);
      } finally {
        FileUtils.close(stream);
      }
    } else {
      OutputStream outputStream = null;
      if (local == null) {
        // test
        logger.debug("Will DLD nullStream: {}", remote);
        outputStream = new NullOutputStream();
      } else {
        logger.debug("Will DLD to local: {} into {}", remote, local);
        outputStream = new FileOutputStream(local);
      }
      try {
        return transferFile(outputStream, remote);
      } finally {
        FileUtils.close(outputStream);
      }
    }
  }

  @Override
  public final boolean transferFile(final InputStream local,
                                    final String remote,
                                    final int getStoreOrAppend) {
    result = null;
    result = CANNOT_FINALIZE_STORE_LIKE_OPERATION;
    logger.debug("Will STOR to: {}", remote);
    try {
      internalTransferFile(local, remote, getStoreOrAppend);
      return true;
    } catch (final Exception e) {
      try {
        reconnect();
        return internalTransferFile(local, remote, getStoreOrAppend);
      } catch (final Exception e1) {
        SysErrLogger.FAKE_LOGGER.ignoreLog(e1);
      }
      logger.error(result + ": {}", e.getMessage());
      return false;
    } finally {
      waitAfterDataCommand();
    }
  }

  private boolean internalTransferFile(final InputStream local,
                                       final String remote,
                                       final int getStoreOrAppend)
      throws IOException, FTPIllegalReplyException, FTPException,
             FTPDataTransferException, FTPAbortedException {
    if (getStoreOrAppend == 1) {
      ftpClient.upload(remote, local, 0, 0, null);
    } else {
      // append
      ftpClient.append(remote, local, 0, null);
    }
    result = null;
    return true;
  }

  @Override
  public final boolean transferFile(final OutputStream local,
                                    final String remote) {
    result = null;
    result = CANNOT_FINALIZE_RETRIEVE_LIKE_OPERATION;
    logger.debug("Will DLD nullStream: {}", remote);
    try {
      ftpClient.download(remote, local, 0, null);
      result = null;
      return true;
    } catch (final Exception e) {
      try {
        reconnect();
        ftpClient.download(remote, local, 0, null);
        result = null;
        return true;
      } catch (final Exception e1) {
        SysErrLogger.FAKE_LOGGER.ignoreLog(e1);
      }
      logger.error(result + ": {}", e.getMessage(), e);
      return false;
    } finally {
      waitAfterDataCommand();
    }
  }

  @Override
  public final String[] listFiles(final String remote) {
    result = null;
    ftpClient.setMLSDPolicy(FTPClient.MLSD_NEVER);
    try {
      return internalListFiles(remote);
    } catch (final Exception e) {
      try {
        reconnect();
        return internalListFiles(remote);
      } catch (final Exception e1) {
        SysErrLogger.FAKE_LOGGER.ignoreLog(e1);
      }
      result = CANNOT_FINALIZE_TRANSFER_OPERATION;
      logger.error(result + ": {}", e.getMessage());
      return null;
    } finally {
      waitAfterDataCommand();
    }
  }

  private String[] internalListFiles(final String remote)
      throws IOException, FTPIllegalReplyException, FTPException,
             FTPDataTransferException, FTPAbortedException,
             FTPListParseException {
    final FTPFile[] list = ftpClient.list(remote);
    final String[] results = new String[list.length];
    int i = 0;
    for (final FTPFile file : list) {
      results[i] = file.toString();
      i++;
    }
    return results;
  }

  @Override
  public final String[] listFiles() {
    return listFiles((String) null);
  }

  @Override
  public final String[] mlistFiles(final String remote) {
    ftpClient.setMLSDPolicy(FTPClient.MLSD_ALWAYS);
    return listFiles(remote);
  }

  @Override
  public final String[] mlistFiles() {
    return mlistFiles((String) null);
  }


  @Override
  public final String[] features() {
    result = null;
    try {
      final FTPReply reply = ftpClient.sendCustomCommand("FEAT");
      return reply.getMessages();
    } catch (final IOException e) {
      try {
        reconnect();
        final FTPReply reply = ftpClient.sendCustomCommand("FEAT");
        return reply.getMessages();
      } catch (final Exception e1) {
        SysErrLogger.FAKE_LOGGER.ignoreLog(e1);
      }
      result = CANNOT_EXECUTE_OPERATION_FEATURE;
      logger.error(result + ": {}", e.getMessage());
      return null;
    } catch (final Exception e) {
      try {
        reconnect();
        final FTPReply reply = ftpClient.sendCustomCommand("FEAT");
        return reply.getMessages();
      } catch (final Exception e1) {
        SysErrLogger.FAKE_LOGGER.ignoreLog(e1);
      }
      result = CANNOT_EXECUTE_OPERATION_FEATURE;
      logger.error(result + ": {}", e.getMessage());
      return null;
    }
  }

  @Override
  public final boolean featureEnabled(final String feature) {
    result = null;
    try {
      return internalFeatureEnabled(feature);
    } catch (final Exception e) {
      try {
        reconnect();
        return internalFeatureEnabled(feature);
      } catch (final Exception e1) {
        SysErrLogger.FAKE_LOGGER.ignoreLog(e1);
      }
      result = CANNOT_EXECUTE_OPERATION_FEATURE;
      logger.error(result + ": {}", e.getMessage());
      return false;
    }
  }

  private boolean internalFeatureEnabled(final String feature)
      throws IOException, FTPIllegalReplyException {
    final FTPReply reply = ftpClient.sendCustomCommand("FEAT");
    final String[] msg = reply.getMessages();
    for (final String string : msg) {
      if (string.contains(feature.toUpperCase())) {
        return true;
      }
    }
    return false;
  }

  @Override
  public boolean deleteFile(final String remote) {
    result = null;
    try {
      logger.debug("DELE {}", remote);
      ftpClient.deleteFile(remote);
      return true;
    } catch (final Exception e) {
      try {
        reconnect();
        ftpClient.deleteFile(remote);
        return true;
      } catch (final Exception e1) {
        SysErrLogger.FAKE_LOGGER.ignoreLog(e1);
      }
      result = CANNOT_EXECUTE_OPERATION_SITE;
      logger.error(result + ": {}", e.getMessage());
      return false;
    }
  }

  @Override
  public final String[] executeCommand(final String params) {
    result = null;
    try {
      logger.debug(params);
      FTPReply reply = ftpClient.sendCustomCommand(params);
      if (!reply.isSuccessCode()) {
        reconnect();
        reply = ftpClient.sendCustomCommand(params);
        if (!reply.isSuccessCode()) {
          result = reply.toString();
          return null;
        }
      }
      return reply.getMessages();
    } catch (final Exception e) {
      try {
        reconnect();
        final FTPReply reply = ftpClient.sendCustomCommand(params);
        if (!reply.isSuccessCode()) {
          result = reply.toString();
          return null;
        }
        return reply.getMessages();
      } catch (final Exception e1) {
        SysErrLogger.FAKE_LOGGER.ignoreLog(e1);
      }
      result = CANNOT_EXECUTE_OPERATION_SITE;
      logger.error(result + ": {}", e.getMessage());
      return null;
    }
  }

  @Override
  public final String[] executeSiteCommand(final String params) {
    result = null;
    try {
      logger.debug("SITE {}", params);
      FTPReply reply = ftpClient.sendSiteCommand(params);
      if (!reply.isSuccessCode()) {
        reconnect();
        reply = ftpClient.sendSiteCommand(params);
        if (!reply.isSuccessCode()) {
          result = reply.toString();
          return null;
        }
      }
      return reply.getMessages();
    } catch (final Exception e) {
      try {
        reconnect();
        final FTPReply reply = ftpClient.sendSiteCommand(params);
        if (!reply.isSuccessCode()) {
          result = reply.toString();
          return null;
        }
        return reply.getMessages();
      } catch (final Exception e1) {
        SysErrLogger.FAKE_LOGGER.ignoreLog(e1);
      }
      result = CANNOT_EXECUTE_OPERATION_SITE;
      logger.error(result + ": {}", e.getMessage());
      return null;
    }
  }

  @Override
  public final void noop() {
    try {
      ftpClient.noop();
    } catch (final Exception e) {
      try {
        reconnect();
        ftpClient.noop();
      } catch (final Exception e1) {
        SysErrLogger.FAKE_LOGGER.ignoreLog(e1);
      }
      result = NOOP_ERROR;
      logger.error(result + ": {}", e.getMessage());
    }
  }

  /**
   * Used on Data Commands to prevent too fast command iterations
   */
  static void waitAfterDataCommand() {
    if (DEFAULT_WAIT > 0) {
      try {
        Thread.sleep(DEFAULT_WAIT);
      } catch (final InterruptedException e) { //NOSONAR
        SysErrLogger.FAKE_LOGGER.ignoreLog(e);
      }
    } else {
      Thread.yield();
    }
  }
}