AbstractInitCommand.java

/*
 * Syncany, www.syncany.org
 * Copyright (C) 2011-2016 Philipp C. Heckel <philipp.heckel@gmail.com>
 *
 * This program 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.
 *
 * This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package org.syncany.cli;

import java.io.IOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.net.URI;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.logging.Level;
import java.util.logging.Logger;

import joptsimple.OptionSet;
import joptsimple.OptionSpec;

import org.syncany.cli.util.InitConsole;
import org.syncany.config.to.ConfigTO;
import org.syncany.crypto.CipherUtil;
import org.syncany.operations.daemon.messages.ShowMessageExternalEvent;
import org.syncany.operations.init.GenlinkOperationResult;
import org.syncany.plugins.Plugins;
import org.syncany.plugins.UserInteractionListener;
import org.syncany.plugins.transfer.NestedTransferPluginOption;
import org.syncany.plugins.transfer.StorageException;
import org.syncany.plugins.transfer.StorageTestResult;
import org.syncany.plugins.transfer.TransferPlugin;
import org.syncany.plugins.transfer.TransferPluginOption;
import org.syncany.plugins.transfer.TransferPluginOption.ValidationResult;
import org.syncany.plugins.transfer.TransferPluginOptionCallback;
import org.syncany.plugins.transfer.TransferPluginOptionConverter;
import org.syncany.plugins.transfer.TransferPluginOptions;
import org.syncany.plugins.transfer.TransferPluginUtil;
import org.syncany.plugins.transfer.TransferSettings;
import org.syncany.plugins.transfer.oauth.OAuth;
import org.syncany.plugins.transfer.oauth.OAuthGenerator;
import org.syncany.plugins.transfer.oauth.OAuthTokenFinish;
import org.syncany.plugins.transfer.oauth.OAuthTokenWebListener;
import org.syncany.util.ReflectionUtil;
import org.syncany.util.StringUtil;
import org.syncany.util.StringUtil.StringJoinListener;

import com.google.common.base.Predicate;
import com.google.common.base.Strings;
import com.google.common.collect.Iterables;
import com.google.common.eventbus.Subscribe;

/**
 * The abstract init command provides multiple shared methods for the 'init'
 * and 'connect' command. Both commands must provide the ability to
 * query a user for transfer settings or parse settings from the command line
 *
 * @author Philipp C. Heckel (philipp.heckel@gmail.com)
 * @author Christian Roth (christian.roth@port17.de)
 */
public abstract class AbstractInitCommand extends Command implements UserInteractionListener {
	private static final Logger logger = Logger.getLogger(AbstractInitCommand.class.getName());

	protected static final char NESTED_OPTIONS_SEPARATOR = '.';
	protected static final String GENERIC_PLUGIN_TYPE_IDENTIFIER = ":type";
	protected static final int PASSWORD_MIN_LENGTH = 10;
	protected static final int PASSWORD_WARN_LENGTH = 12;
	protected static final int OAUTH_TOKEN_WAIT_TIMEOUT = 60;

	protected InitConsole console;
	protected boolean isInteractive;
	protected boolean isHeadless;

	public AbstractInitCommand() {
		console = InitConsole.getInstance();
	}

	protected ConfigTO createConfigTO(TransferSettings transferSettings) throws Exception {
		ConfigTO configTO = new ConfigTO();

		configTO.setDisplayName(getDefaultDisplayName());
		configTO.setMachineName(getRandomMachineName());
		configTO.setMasterKey(null);
		configTO.setTransferSettings(transferSettings); // can be null

		return configTO;
	}

	protected TransferSettings createTransferSettingsFromOptions(OptionSet options, OptionSpec<String> optionPlugin,
			OptionSpec<String> optionPluginOpts) throws Exception {

		TransferPlugin plugin;
		TransferSettings transferSettings;

		// Parse --plugin and --plugin-option values
		List<String> pluginOptionStrings = options.valuesOf(optionPluginOpts);
		Map<String, String> knownPluginSettings = parsePluginSettingsFromOptions(pluginOptionStrings);

		// Validation of some constraints
		if (!options.has(optionPlugin) && knownPluginSettings.size() > 0) {
			throw new IllegalArgumentException("Provided plugin settings without a plugin name.");
		}

		plugin = options.has(optionPlugin) ? initPlugin(options.valueOf(optionPlugin)) : askPlugin();
		transferSettings = askPluginSettings(plugin.createEmptySettings(), knownPluginSettings);

		return transferSettings;
	}

	private Map<String, String> parsePluginSettingsFromOptions(List<String> pluginSettingsOptList) throws Exception {
		Map<String, String> pluginOptionValues = new HashMap<>();

		// Fill settings map
		for (String pluginSettingKeyValue : pluginSettingsOptList) {
			String[] keyValue = pluginSettingKeyValue.split("=", 2);

			if (keyValue.length != 2) {
				throw new Exception("Invalid setting: " + pluginSettingKeyValue);
			}

			pluginOptionValues.put(keyValue[0], keyValue[1]);
		}

		return pluginOptionValues;
	}

	private TransferPlugin initPlugin(String pluginStr) throws Exception {
		TransferPlugin plugin = Plugins.get(pluginStr, TransferPlugin.class);

		if (plugin == null) {
			throw new Exception("ERROR: Plugin '" + pluginStr + "' does not exist.");
		}

		return plugin;
	}

	private TransferSettings askPluginSettings(TransferSettings settings, Map<String, String> knownPluginSettings) throws StorageException {
		if (isInteractive) {
			out.println();
			out.println("Connection details for " + settings.getType() + " connection:");
		}
		else {
			logger.log(Level.INFO, "Non interactive mode");
		}

		try {
			// Show OAuth output
			printOAuthInformation(settings);

			// Ask for plugin settings
			List<TransferPluginOption> pluginOptions = TransferPluginOptions.getOrderedOptions(settings.getClass());

			for (TransferPluginOption option : pluginOptions) {
				askPluginSettings(settings, option, knownPluginSettings, "");
			}
		}
		catch (NoSuchFieldException e) {
			logger.log(Level.SEVERE, "No token could be found, maybe user denied access", e);
			throw new StorageException("No token found. Did you accept the authorization?", e);
		}
		catch (TimeoutException e) {
			logger.log(Level.SEVERE, "No token was received in the given time interval", e);
			throw new StorageException("No token was received in the given time interval", e);
		}
		catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException | IOException | InterruptedException | ExecutionException e) {
			logger.log(Level.SEVERE, "Unable to execute option generator", e);
			throw new RuntimeException("Unable to execute option generator: " + e.getMessage());
		}

		if (!settings.isValid()) {
			if (askRetryInvalidSettings(settings.getReasonForLastValidationFail())) {
				return askPluginSettings(settings, knownPluginSettings);
			}

			throw new StorageException("Validation failed: " + settings.getReasonForLastValidationFail());
		}

		logger.log(Level.INFO, "Settings are " + settings.toString());

		return settings;
	}

	private void printOAuthInformation(TransferSettings settings) throws StorageException, NoSuchMethodException, SecurityException,
					InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, IOException, ExecutionException, InterruptedException, TimeoutException, NoSuchFieldException {
		OAuth oAuthSettings =	settings.getClass().getAnnotation(OAuth.class);

		if (oAuthSettings != null) {
			Constructor<? extends OAuthGenerator> optionCallbackClassConstructor = oAuthSettings.value().getDeclaredConstructor(settings.getClass());
			OAuthGenerator oAuthGenerator = optionCallbackClassConstructor.newInstance(settings);

			if (isHeadless) {
				logger.log(Level.FINE, "User is in headless mode and the plugin is OAuth based");

				if (oAuthGenerator instanceof OAuthGenerator.WithNoRedirectMode) {
					doOAuthInCopyTokenMode(oAuthGenerator);
				}
				else {
					throw new RuntimeException("OAuth based plugin does not support headless mode");
				}
			}
			else {
				doOAuthInRedirectMode(oAuthGenerator, oAuthSettings);
			}

		}
	}

	private void doOAuthInCopyTokenMode(OAuthGenerator generator) throws StorageException {
		URI oAuthURL = ((OAuthGenerator.WithNoRedirectMode) generator).generateAuthUrl();

		out.println();
		out.println("This plugin needs you to authenticate your account so that Syncany can access it.");
		out.printf("Please navigate to the URL below and enter the token:\n\n  %s\n\n", oAuthURL.toString());
		out.print("- Token (paste from URL): ");

		String token = console.readLine();
		generator.checkToken(token, null);
	}

	private void doOAuthInRedirectMode(OAuthGenerator generator, OAuth settings) throws IOException, InterruptedException, ExecutionException, TimeoutException, StorageException {
		OAuthTokenWebListener.Builder tokenListerBuilder = OAuthTokenWebListener.forMode(settings.mode());

		if (settings.callbackPort() != OAuth.RANDOM_PORT) {
			tokenListerBuilder.setPort(settings.callbackPort());
		}

		if (!settings.callbackId().equals(OAuth.PLUGIN_ID)) {
			tokenListerBuilder.setId(settings.callbackId());
		}

		// non standard plugin?
		if (generator instanceof OAuthGenerator.WithInterceptor) {
			tokenListerBuilder.setTokenInterceptor(((OAuthGenerator.WithInterceptor) generator).getInterceptor());
		}

		if (generator instanceof OAuthGenerator.WithExtractor) {
			tokenListerBuilder.setTokenExtractor(((OAuthGenerator.WithExtractor) generator).getExtractor());
		}

		OAuthTokenWebListener tokenListener = tokenListerBuilder.build();

		URI oAuthURL = generator.generateAuthUrl(tokenListener.start());
		Future<OAuthTokenFinish> futureTokenResponse = tokenListener.getToken();

		out.println();
		out.println("This plugin needs you to authenticate your account so that Syncany can access it.");
		out.printf("Please navigate to the URL below and accept the given permissions:\n\n  %s\n\n", oAuthURL.toString());
		out.print("Waiting for authorization...");

		OAuthTokenFinish tokenResponse = futureTokenResponse.get(OAUTH_TOKEN_WAIT_TIMEOUT, TimeUnit.SECONDS);

		if (tokenResponse != null) {
			out.printf(" received token '%s'\n\n", tokenResponse.getToken());
			generator.checkToken(tokenResponse.getToken(), tokenResponse.getCsrfState());
		}
		else {
			out.println(" canceled");
			throw new StorageException("Error while acquiring token, perhaps user denied authorization");
		}
	}

	private void askPluginSettings(TransferSettings settings, TransferPluginOption option, Map<String, String> knownPluginSettings, String nestPrefix)
			throws IllegalAccessException, InstantiationException, StorageException, IllegalArgumentException, InvocationTargetException,
			NoSuchMethodException, SecurityException {

		if (option instanceof NestedTransferPluginOption) {
			Class<?> childPluginTransferSettingsClass = ReflectionUtil.getClassFromType(option.getType());
			boolean isGenericChildPlugin = TransferSettings.class.equals(childPluginTransferSettingsClass);

			if (isGenericChildPlugin) {
				askGenericChildPluginSettings(settings, option, knownPluginSettings, nestPrefix);
			}
			else {
				askConreteChildPluginSettings(settings, (NestedTransferPluginOption) option, knownPluginSettings, nestPrefix);
			}
		}
		else {
			askNormalPluginSettings(settings, option, knownPluginSettings, nestPrefix);
		}
	}

	private void askNormalPluginSettings(TransferSettings settings, TransferPluginOption option, Map<String, String> knownPluginSettings,
			String nestPrefix)
			throws StorageException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException,
			NoSuchMethodException, SecurityException {

		TransferPluginOptionCallback optionCallback = createOptionCallback(settings, option.getCallback());
		TransferPluginOptionConverter optionConverter = createOptionConverter(settings, option.getConverter());

		if (!isInteractive && !knownPluginSettings.containsKey(nestPrefix + option.getName())) {
			throw new IllegalArgumentException("Missing plugin option (" + nestPrefix + option.getName() + ") in non-interactive mode.");
		}
		else if (knownPluginSettings.containsKey(nestPrefix + option.getName())) {
			settings.setField(option.getField().getName(), knownPluginSettings.get(nestPrefix + option.getName()));
		}
		else if (!option.isVisible()) {
			// Do nothing. Invisible option!
		}
		else {
			callAndPrintPreQueryCallback(optionCallback);

			String optionValue = askPluginOption(settings, option);

			if (optionConverter != null) {
				optionValue = optionConverter.convert(optionValue);
			}

			settings.setField(option.getField().getName(), optionValue);

			callAndPrintPostQueryCallback(optionCallback, optionValue);
		}
	}

	/**
	 * Queries the user for a plugin (which plugin to use?) and then
	 * asks for all of the plugin's settings.
	 *
	 * <p>This case is triggered by a field looking like this:
	 * <code>private TransferSettings childPluginSettings;</code>
	 */
	private void askGenericChildPluginSettings(TransferSettings settings, TransferPluginOption option, Map<String, String> knownPluginSettings,
			String nestPrefix)
			throws StorageException, IllegalAccessException, InstantiationException, IllegalArgumentException, InvocationTargetException,
			NoSuchMethodException, SecurityException {

		TransferPluginOptionCallback optionCallback = createOptionCallback(settings, option.getCallback());

		if (isInteractive) {
			callAndPrintPreQueryCallback(optionCallback);

			out.println();
			out.println(option.getDescription() + ":");
		}

		TransferPlugin childPlugin = null;
		Class<? extends TransferPlugin> pluginClass = TransferPluginUtil.getTransferPluginClass(settings.getClass());

		// Non-interactive: Plugin settings might be given via command line
		try {
			childPlugin = initPlugin(knownPluginSettings.get(nestPrefix + option.getName() + GENERIC_PLUGIN_TYPE_IDENTIFIER));
		}
		catch (Exception e) {
			if (!isInteractive) {
				throw new IllegalArgumentException("Missing nested plugin type (" + nestPrefix + option.getName() + GENERIC_PLUGIN_TYPE_IDENTIFIER
						+ ") in non-interactive mode.");
			}
		}

		// Interactive mode: Ask for sub-plugin
		while (childPlugin == null) {
			childPlugin = askPlugin(pluginClass);
		}

		if (isInteractive) {
			out.println();
		}

		// Create nested/child settings
		TransferSettings childSettings = childPlugin.createEmptySettings();

		settings.setField(option.getField().getName(), childSettings);
		nestPrefix = nestPrefix + option.getName() + NESTED_OPTIONS_SEPARATOR;

		for (TransferPluginOption nestedOption : TransferPluginOptions.getOrderedOptions(childSettings.getClass())) {
			askPluginSettings(childSettings, nestedOption, knownPluginSettings, nestPrefix);
		}

		if (isInteractive) {
			callAndPrintPostQueryCallback(optionCallback, null);
		}
	}

	/**
	 * Asks the user for all of the child plugin's settings.
	 *
	 * <p>This case is triggered by a field looking like this:
	 * <code>private LocalTransferSettings localChildPluginSettings;</code>
	 */
	private void askConreteChildPluginSettings(TransferSettings settings, NestedTransferPluginOption option, Map<String, String> knownPluginSettings,
			String nestPrefix) throws StorageException, IllegalAccessException, InstantiationException, IllegalArgumentException,
			InvocationTargetException, NoSuchMethodException, SecurityException {

		TransferPluginOptionCallback optionCallback = createOptionCallback(settings, option.getCallback());

		if (isInteractive) {
			callAndPrintPreQueryCallback(optionCallback);

			out.println();
			out.println(option.getDescription() + ":");
		}

		for (TransferPluginOption nestedPluginOption : option.getOptions()) {
			Class<?> nestedTransferSettingsClass = ReflectionUtil.getClassFromType(option.getType());

			if (nestedTransferSettingsClass == null) {
				throw new RuntimeException("No class found for type: " + option.getType());
			}

			TransferSettings nestedSettings = (TransferSettings) nestedTransferSettingsClass.newInstance();

			settings.setField(option.getField().getName(), nestedSettings);
			nestPrefix = nestPrefix + option.getName() + NESTED_OPTIONS_SEPARATOR;

			askPluginSettings(nestedSettings, nestedPluginOption, knownPluginSettings, nestPrefix);
		}

		if (isInteractive) {
			callAndPrintPostQueryCallback(optionCallback, null);
		}
	}

	private void callAndPrintPreQueryCallback(TransferPluginOptionCallback optionCallback) {
		if (optionCallback != null) {
			String preQueryMessage = optionCallback.preQueryCallback();

			if (preQueryMessage != null) {
				out.println(preQueryMessage);
			}
		}
	}

	private void callAndPrintPostQueryCallback(TransferPluginOptionCallback optionCallback, String optionValue) {
		if (optionCallback != null) {
			String postQueryMessage = optionCallback.postQueryCallback(optionValue);

			if (postQueryMessage != null) {
				out.println(postQueryMessage);
			}
		}
	}

	private String askPluginOption(TransferSettings settings, TransferPluginOption option) throws StorageException {
		while (true) {
			String value;

			// Retrieve value
			if (option.isSensitive()) {
				// The option is sensitive. Could be either mandatory or optional
				value = askPluginOptionSensitive(settings, option);
			}
			else if (!option.isRequired()) {
				// The option is optional
				value = askPluginOptionOptional(settings, option);
			}
			else {
				// The option is mandatory, but not sensitive
				value = askPluginOptionNormal(settings, option);
			}

			if ("".equals(value)) {
				value = null;
			}

			// Validate result
			ValidationResult validationResult = option.isValid(value);

			switch (validationResult) {
			case INVALID_NOT_SET:
				out.println("ERROR: This option is mandatory.");
				out.println();
				break;

			case INVALID_TYPE:
				out.println("ERROR: Not a valid input.");
				out.println();
				break;

			case VALID:
				return value;

			default:
				throw new RuntimeException("Invalid return type: " + validationResult);
			}
		}
	}

	private String askPluginOptionNormal(TransferSettings settings, TransferPluginOption option) throws StorageException {
		String knownOptionValue = settings.getField(option.getField().getName());
		String value = knownOptionValue;

		if (option.isSingular() || knownOptionValue == null || "".equals(knownOptionValue)) {
			out.printf("- %s: ", getDescription(settings, option));
			value = console.readLine();
		}
		else {
			out.printf("- %s (%s): ", getDescription(settings, option), knownOptionValue);
			value = console.readLine();

			if ("".equals(value)) {
				value = knownOptionValue;
			}
		}

		return value;
	}

	private String askPluginOptionOptional(TransferSettings settings, TransferPluginOption option) throws StorageException {
		String knownOptionValue = settings.getField(option.getField().getName());
		String value = knownOptionValue;

		if (knownOptionValue == null || "".equals(knownOptionValue)) {
			String defaultValueDescription = settings.getField(option.getField().getName());

			if (defaultValueDescription == null) {
				defaultValueDescription = "none";
			}

			out.printf("- %s (optional, default is %s): ", getDescription(settings, option), defaultValueDescription);
			value = console.readLine();
		}
		else {
			out.printf("- %s (%s): ", getDescription(settings, option), knownOptionValue);
			value = console.readLine();

			if ("".equals(value)) {
				value = knownOptionValue;
			}
		}

		return value;
	}

	private String askPluginOptionSensitive(TransferSettings settings, TransferPluginOption option) throws StorageException {
		String knownOptionValue = settings.getField(option.getField().getName());
		String value = knownOptionValue;
		String optionalIndicator = option.isRequired() ? "" : ", optional";

		if (option.isSingular() || knownOptionValue == null || "".equals(knownOptionValue)) {
			out.printf("- %s (not displayed%s): ", getDescription(settings, option), optionalIndicator);
			value = String.copyValueOf(console.readPassword());
		}
		else {
			out.printf("- %s (***, not displayed%s): ", getDescription(settings, option), optionalIndicator);
			value = String.copyValueOf(console.readPassword());

			if ("".equals(value)) {
				value = knownOptionValue;
			}
		}

		return value;
	}

	private String getDescription(TransferSettings settings, TransferPluginOption option) {
		Class<?> clazzForType = ReflectionUtil.getClassFromType(option.getType());

		if (clazzForType != null && Enum.class.isAssignableFrom(clazzForType)) {
			Object[] enumValues = clazzForType.getEnumConstants();

			if (enumValues == null) {
				throw new RuntimeException("Invalid TransferSettings class found: Enum at " + settings + " has no values");
			}

			logger.log(Level.FINE, "Found enum option, values are: " + StringUtil.join(enumValues, ", "));

			return String.format("%s, choose from %s", option.getDescription(), StringUtil.join(enumValues, ", "));
		}
		else {
			return option.getDescription();
		}
	}

	protected TransferPlugin askPlugin() {
		return askPlugin(null);
	}

	protected TransferPlugin askPlugin(final Class<? extends TransferPlugin> ignoreTransferPluginClass) {
		TransferPlugin plugin = null;
		final List<TransferPlugin> plugins = Plugins.list(TransferPlugin.class);

		Iterables.removeIf(plugins, new Predicate<TransferPlugin>() {
			@Override
			public boolean apply(TransferPlugin transferPlugin) {
				return ignoreTransferPluginClass == transferPlugin.getClass();
			}
		});

		String pluginsList = StringUtil.join(plugins, ", ", new StringJoinListener<TransferPlugin>() {
			@Override
			public String getString(TransferPlugin plugin) {
				return plugin.getId();
			}
		});

		while (plugin == null) {
			out.println("Choose a storage plugin. Available plugins are: " + pluginsList);
			out.print("Plugin: ");
			String pluginStr = console.readLine();

			plugin = Plugins.get(pluginStr, TransferPlugin.class);

			if (plugin == null || ignoreTransferPluginClass == plugin.getClass()) {
				out.println("ERROR: Plugin does not exist or cannot be used.");
				out.println();

				plugin = null;
			}
		}

		return plugin;
	}

	private TransferPluginOptionConverter createOptionConverter(TransferSettings settings,
			Class<? extends TransferPluginOptionConverter> optionConverterClass) throws InstantiationException, IllegalAccessException,
			IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException {

		TransferPluginOptionConverter optionConverter = null;

		if (optionConverterClass != null) {
			Constructor<? extends TransferPluginOptionConverter> optionConverterClassConstructor = optionConverterClass.getDeclaredConstructor(settings.getClass());
			optionConverter = optionConverterClassConstructor.newInstance(settings);
		}

		return optionConverter;
	}

	private TransferPluginOptionCallback createOptionCallback(TransferSettings settings,
			Class<? extends TransferPluginOptionCallback> optionCallbackClass) throws InstantiationException, IllegalAccessException,
			IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException {

		TransferPluginOptionCallback optionCallback = null;

		if (optionCallbackClass != null) {
			Constructor<? extends TransferPluginOptionCallback> optionCallbackClassConstructor = optionCallbackClass.getDeclaredConstructor(settings.getClass());
			optionCallback = optionCallbackClassConstructor.newInstance(settings);
		}

		return optionCallback;
	}

	protected String getRandomMachineName() {
		return CipherUtil.createRandomAlphabeticString(20);
	}

	protected String getDefaultDisplayName() throws UnknownHostException {
		return System.getProperty("user.name");
	}

	protected boolean askRetryInvalidSettings(String failReason) {
		return onUserConfirm("Validation failure", failReason, "Would you change the settings");
	}

	protected boolean askRetryConnection() {
		return onUserConfirm(null, "Connection failure", "Would you change the settings and retry the connection");
	}

	protected TransferSettings updateTransferSettings(TransferSettings transferSettings) throws StorageException {
		try {
			return askPluginSettings(transferSettings, new HashMap<String, String>());
		}
		catch (Exception e) {
			logger.log(Level.SEVERE, "Unable to reload old plugin settings", e);
			throw new StorageException("Unable to reload old plugin settings: " + e.getMessage());
		}
	}

	protected void printLink(GenlinkOperationResult operationResult, boolean shortOutput) {
		if (shortOutput) {
			out.println(operationResult.getShareLink());
		}
		else {
			out.println();
			out.println("   " + operationResult.getShareLink());
			out.println();

			if (operationResult.isShareLinkEncrypted()) {
				out.println("This link is encrypted with the given password, so you can safely share it.");
				out.println("using unsecure communication (chat, e-mail, etc.)");
				out.println();
				out.println("Note: The link contains the details of your repo connection which typically");
				out.println("      consist of usernames/password of the connection (e.g. FTP user/pass).");
			}
			else {
				out.println("WARNING: This link is NOT ENCRYPTED and might contain connection credentials");
				out.println("         Do NOT share this link unless you know what you are doing!");
				out.println();
				out.println("         The link contains the details of your repo connection which typically");
				out.println("         consist of usernames/password of the connection (e.g. FTP user/pass).");
			}

			out.println();
		}
	}

	protected void printTestResult(StorageTestResult testResult) {
		out.println("Details:");
		out.println("- Target connect success: " + testResult.isTargetCanConnect());
		out.println("- Target exists:          " + testResult.isTargetExists());
		out.println("- Target creatable:       " + testResult.isTargetCanCreate());
		out.println("- Target writable:        " + testResult.isTargetCanWrite());
		out.println("- Repo file exists:       " + testResult.isRepoFileExists());
		out.println();

		if (testResult.getErrorMessage() != null) {
			out.println("Error message (see log file for details):");
			out.println("  " + testResult.getErrorMessage());
		}
	}

	@Override
	public boolean onUserConfirm(String header, String message, String question) {
		if (header != null) {
			out.println();
			out.println(header);
			out.println(Strings.repeat("-", header.length()));
		}

		out.println(message);
		out.println();

		String yesno = console.readLine(question + " (y/n)? ");

		if (!yesno.toLowerCase().startsWith("y") && !"".equals(yesno)) {
			return false;
		}
		else {
			return true;
		}
	}

	@Subscribe
	public void onShowMessage(ShowMessageExternalEvent messageEvent) {
		out.println();
		out.println(messageEvent.getMessage());
	}

	@Override
	public String onUserPassword(String header, String message) {
		if (!isInteractive) {
			throw new RuntimeException("Repository is encrypted, but no password was given in non-interactive mode.");
		}

		out.println();

		if (header != null) {
			out.println(header);
			out.println(Strings.repeat("-", header.length()));
		}

		if (!message.trim().endsWith(":")) {
			message += ": ";
		}

		char[] passwordChars = console.readPassword(message);
		return String.copyValueOf(passwordChars);
	}

	@Override
	public String onUserNewPassword() {
		out.println();
		out.println("The password is used to encrypt data on the remote storage.");
		out.println("Choose wisely!");
		out.println();

		String password = null;

		while (password == null) {
			char[] passwordChars = console.readPassword("Password (min. " + PASSWORD_MIN_LENGTH + " chars): ");

			if (passwordChars.length < PASSWORD_MIN_LENGTH) {
				out.println("ERROR: This password is not allowed (too short, min. " + PASSWORD_MIN_LENGTH + " chars)");
				out.println();

				continue;
			}

			char[] confirmPasswordChars = console.readPassword("Confirm: ");

			if (!Arrays.equals(passwordChars, confirmPasswordChars)) {
				out.println("ERROR: Passwords do not match.");
				out.println();

				continue;
			}

			if (passwordChars.length < PASSWORD_WARN_LENGTH) {
				out.println();
				out.println("WARNING: The password is a bit short. Less than " + PASSWORD_WARN_LENGTH + " chars are not future-proof!");
				String yesno = console.readLine("Are you sure you want to use it (y/n)? ");

				if (!yesno.toLowerCase().startsWith("y") && !"".equals(yesno)) {
					out.println();
					continue;
				}
			}

			password = new String(passwordChars);
		}

		return password;
	}
}