PluginOperation.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.operations.plugin;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.jar.JarInputStream;
import java.util.jar.Manifest;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.simpleframework.xml.core.Persister;
import org.syncany.Client;
import org.syncany.config.Config;
import org.syncany.config.LocalEventBus;
import org.syncany.config.UserConfig;
import org.syncany.crypto.CipherUtil;
import org.syncany.operations.Operation;
import org.syncany.operations.daemon.messages.ConnectToHostExternalEvent;
import org.syncany.operations.daemon.messages.PluginInstallExternalEvent;
import org.syncany.operations.plugin.PluginOperationOptions.PluginListMode;
import org.syncany.operations.plugin.PluginOperationResult.PluginResultCode;
import org.syncany.plugins.Plugin;
import org.syncany.plugins.Plugins;
import org.syncany.util.EnvironmentUtil;
import org.syncany.util.FileUtil;
import org.syncany.util.StringUtil;

import com.github.zafarkhaja.semver.Version;
import com.google.common.base.Function;
import com.google.common.base.Predicate;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;

/**
 * The plugin operation installs, removes and lists storage {@link Plugin}s.
 *
 * <p>The plugin implements these three functionalities as different
 * {@link PluginOperationAction}:
 *
 * <ul>
 * <li><code>INSTALL</code>: Installation means copying a file to the user plugin directory
 * as specified by {@link UserConfig#getUserPluginLibDir()}. A plugin can be installed
 * from a local JAR file, a URL (the operation downloads a JAR file), or the
 * API host (the operation find the plugin using the 'list' action and downloads
 * the JAR file).</li>
 * <li><code>REMOVE</code>: Removal means deleting a JAR file from the user plugin
 * directoryThis action. This action simply finds the responsible plugin JAR
 * file and deletes it. Only JAR files inside the user plugin direcory can be
 * deleted.</li>
 * <li><code>LIST</code>: Listing refers to a local and a remote list. The locally installed
 * plugins can be queried by {@link Plugins#list()}. These plugins' JAR files must be
 * in the application's class path. Remotely available plugins are queried through the
 * API.</li>
 * </ul>
 *
 * @author Philipp C. Heckel (philipp.heckel@gmail.com)
 */
public class PluginOperation extends Operation {
	private static final Logger logger = Logger.getLogger(PluginOperation.class.getSimpleName());

	private static final String API_DEFAULT_ENDPOINT_URL = "https://api.syncany.org/v3";
	private static final String API_PLUGIN_LIST_REQUEST_FORMAT = "%s/plugins/list?appVersion=%s&snapshots=%s&pluginId=%s&os=%s&arch=%s";

	private static final String PURGEFILE_FILENAME = "purgefile";
	private static final String UPDATE_FILENAME = "updatefile";

	private PluginOperationOptions options;
	private PluginOperationResult result;

	private LocalEventBus eventBus;

	public PluginOperation(Config config, PluginOperationOptions options) {
		super(config);

		this.options = options;
		this.result = new PluginOperationResult();

		this.eventBus = LocalEventBus.getInstance();
	}

	@Override
	public PluginOperationResult execute() throws Exception {
		result.setAction(options.getAction());

		switch (options.getAction()) {
			case LIST:
				return executeList();

			case INSTALL:
				return executeInstall();

			case REMOVE:
				return executeRemove();

			case UPDATE:
				return executeUpdate();

			default:
				throw new Exception("Unknown action: " + options.getAction());
		}
	}

	private PluginOperationResult executeUpdate() throws Exception {
		List<String> updateablePlugins = findUpdateCandidates();
		List<String> erroneousPlugins = Lists.newArrayList();
		List<String> delayedPlugins = Lists.newArrayList();

		// update only a specific plugin if it is updatable and provided
		String forcePluginId = options.getPluginId();
		logger.log(Level.FINE, "Force plugin is " + forcePluginId);
		if (forcePluginId != null) {
			if (updateablePlugins.contains(forcePluginId)) {
				updateablePlugins = Lists.newArrayList(forcePluginId);
			}
			else {
				logger.log(Level.WARNING, "User requested to update a non-updatable plugin: " + forcePluginId);
				erroneousPlugins.add(forcePluginId);
				updateablePlugins = Lists.newArrayList(); // empty list
			}
		}

		logger.log(Level.INFO, "The following plugins can be automatically updated: " + StringUtil.join(updateablePlugins, ", "));

		for (String pluginId : updateablePlugins) {
			// first remove
			PluginOperationResult removeResult = executeRemove(pluginId);

			if (removeResult.getResultCode() == PluginResultCode.NOK) {
				logger.log(Level.SEVERE, "Unable to remove " + pluginId + " during the update process");
				erroneousPlugins.add(pluginId);
				continue;
			}

			// ... and install again
			if (EnvironmentUtil.isWindows()) {
				logger.log(Level.FINE, "Appending jar to updatefile");
				File updatefilePath = new File(UserConfig.getUserConfigDir(), UPDATE_FILENAME);

				try (PrintWriter out = new PrintWriter(new BufferedWriter(new FileWriter(updatefilePath, true)))) {
					out.println(pluginId + (options.isSnapshots() ?  " --snapshot" : ""));
					delayedPlugins.add(pluginId);
				}
				catch (IOException e) {
					logger.log(Level.SEVERE, "Unable to append to updatefile " + updatefilePath, e);
					erroneousPlugins.add(pluginId);
				}
			}
			else {
				PluginOperationResult installResult = executeInstallFromApiHost(pluginId);

				if (installResult.getResultCode() == PluginResultCode.NOK) {
					logger.log(Level.SEVERE, "Unable to install " + pluginId + " during the update process");
					erroneousPlugins.add(pluginId);
				}
			}
		}

		if (erroneousPlugins.size() > 0 && erroneousPlugins.size() == updateablePlugins.size()) {
			result.setResultCode(PluginResultCode.NOK);
		}
		else {
			result.setResultCode(PluginResultCode.OK);
		}

		result.setUpdatedPluginIds(updateablePlugins);
		result.setErroneousPluginIds(erroneousPlugins);
		result.setDelayedPluginIds(delayedPlugins);

		return result;
	}

	private List<String> findUpdateCandidates() throws Exception {
		List<ExtendedPluginInfo> updateCandidates = executeList().getPluginList();

		Iterables.removeIf(updateCandidates, new Predicate<ExtendedPluginInfo>() {
			@Override
			public boolean apply(ExtendedPluginInfo pluginInfo) {
				return !pluginInfo.isInstalled() || !pluginInfo.canUninstall() || !pluginInfo.isOutdated();
			}
		});

		return Lists.transform(updateCandidates, new Function<ExtendedPluginInfo, String>() {
			@Override
			public String apply(ExtendedPluginInfo pluginInfo) {
				return pluginInfo.getLocalPluginInfo().getPluginId();
			}
		});
	}

	private PluginOperationResult executeRemove() throws Exception {
		return executeRemove(options.getPluginId());
	}

	private PluginOperationResult executeRemove(String pluginId) throws Exception {
		Plugin plugin = Plugins.get(pluginId);

		if (plugin == null) {
			throw new Exception("Plugin not installed.");
		}

		File pluginJarFile = getJarFile(plugin);
		boolean canUninstall = canUninstall(pluginJarFile);

		if (canUninstall) {
			PluginInfo pluginInfo = readPluginInfoFromJar(pluginJarFile);

			logger.log(Level.INFO, "Uninstalling plugin from file " + pluginJarFile);
			boolean deleted = pluginJarFile.delete();

			// JAR files are locked on Windows, adding JAR filename to a list for delayed deletion (by batch file)
			if (EnvironmentUtil.isWindows() || !deleted) {
				logger.log(Level.FINE, "Appending jar to purgefile (" + EnvironmentUtil.isWindows() + ", "+ deleted +")");
				File purgefilePath = new File(UserConfig.getUserConfigDir(), PURGEFILE_FILENAME);

				try (PrintWriter out = new PrintWriter(new BufferedWriter(new FileWriter(purgefilePath, true)))) {
					out.println(pluginJarFile.getAbsolutePath());
				}
				catch (IOException e) {
					logger.log(Level.SEVERE, "Unable to append to purgefile " + purgefilePath, e);
				}
			}

			// refresh plugin list
			Plugins.refresh();

			result.setSourcePluginPath(pluginJarFile.getAbsolutePath());
			result.setAffectedPluginInfo(pluginInfo);
			result.setResultCode(PluginResultCode.OK);
		}
		else {
			logger.log(Level.INFO, "Plugin can NOT be uninstalled because class location not in " + UserConfig.getUserPluginLibDir());
			result.setResultCode(PluginResultCode.NOK);
		}

		return result;
	}

	private boolean canUninstall(File pluginJarFile) {
		File globalUserPluginDir = UserConfig.getUserPluginLibDir();
		return pluginJarFile != null && pluginJarFile.getAbsolutePath().startsWith(globalUserPluginDir.getAbsolutePath());
	}

	private File getJarFile(Plugin plugin) {
		Class<? extends Plugin> pluginClass = plugin.getClass();
		URL pluginClassLocation = pluginClass.getResource('/' + pluginClass.getName().replace('.', '/') + ".class");
		String pluginClassLocationStr = pluginClassLocation.toString();

		logger.log(Level.INFO, "Plugin class is at " + pluginClassLocationStr);

		if (pluginClassLocationStr.startsWith("jar:file:")) {
			int indexStartAfterSchema = "jar:file:".length();
			int indexEndAtExclamationPoint = pluginClassLocationStr.indexOf("!");
			File pluginJarFile = new File(pluginClassLocationStr.substring(indexStartAfterSchema, indexEndAtExclamationPoint));

			logger.log(Level.INFO, "Plugin is in JAR at " + pluginJarFile);
			return pluginJarFile;
		}
		else {
			logger.log(Level.INFO, "Plugin is not in a JAR file. Probably in test environment.");
			return null;
		}
	}

	private PluginOperationResult executeInstall() throws Exception {
		String pluginId = options.getPluginId();
		File potentialLocalPluginJarFile = new File(pluginId);

		if (pluginId.matches("^https?://.+")) {
			return executeInstallFromUrl(pluginId);
		}
		else if (potentialLocalPluginJarFile.exists()) {
			return executeInstallFromLocalFile(potentialLocalPluginJarFile);
		}
		else {
			return executeInstallFromApiHost(pluginId);
		}
	}

	private PluginOperationResult executeInstallFromApiHost(String pluginId) throws Exception {
		checkPluginNotInstalled(pluginId);

		PluginInfo pluginInfo = getRemotePluginInfo(pluginId);

		if (pluginInfo == null) {
			throw new Exception("Plugin with ID '" + pluginId + "' not found");
		}

		checkPluginCompatibility(pluginInfo);

		eventBus.post(new PluginInstallExternalEvent(pluginInfo.getDownloadUrl()));

		File tempPluginJarFile = downloadPluginJar(pluginInfo.getDownloadUrl());
		String expectedChecksum = pluginInfo.getSha256sum();
		String actualChecksum = calculateChecksum(tempPluginJarFile);

		if (expectedChecksum == null || !expectedChecksum.equals(actualChecksum)) {
			throw new Exception("Checksum mismatch. Expected: " + expectedChecksum + ", but was: " + actualChecksum);
		}

		logger.log(Level.INFO, "Plugin JAR checksum verified: " + actualChecksum);

		File targetPluginJarFile = installPlugin(tempPluginJarFile, pluginInfo);

		result.setSourcePluginPath(pluginInfo.getDownloadUrl());
		result.setTargetPluginPath(targetPluginJarFile.getAbsolutePath());
		result.setAffectedPluginInfo(pluginInfo);
		result.setResultCode(PluginResultCode.OK);

		return result;
	}

	private void checkPluginCompatibility(PluginInfo pluginInfo) throws Exception {
		Version applicationVersion = Version.valueOf(Client.getApplicationVersion());
		Version pluginAppMinVersion = Version.valueOf(pluginInfo.getPluginAppMinVersion());

		logger.log(Level.INFO, "Checking plugin compatibility:");
		logger.log(Level.INFO, "- Application version:             " + Client.getApplicationVersion() + "(" + applicationVersion + ")");
		logger.log(Level.INFO, "- Plugin min. application version: " + pluginInfo.getPluginAppMinVersion() + "(" + pluginAppMinVersion + ")");

		if (applicationVersion.lessThan(pluginAppMinVersion)) {
			throw new Exception("Plugin is incompatible to this application version. Plugin min. application version is "
							+ pluginInfo.getPluginAppMinVersion() + ", current application version is " + Client.getApplicationVersion());
		}

		// Verify if any conflicting plugins are installed
		logger.log(Level.INFO, "Checking for conflicting plugins.");

		List<String> conflictingIds = pluginInfo.getConflictingPluginIds();
		List<String> conflictingInstalledIds = new ArrayList<String>();

		if (conflictingIds != null) {
			for (String pluginId : conflictingIds) {
				Plugin plugin = Plugins.get(pluginId);

				if (plugin != null) {
					logger.log(Level.INFO, "- Conflicting plugin " + pluginId + " found.");
					conflictingInstalledIds.add(pluginId);
				}

				logger.log(Level.FINE, "- Conflicting plugin " + pluginId + " not installed");
			}
		}

		result.setConflictingPlugins(conflictingInstalledIds);
	}

	private String calculateChecksum(File tempPluginJarFile) throws Exception {
		CipherUtil.enableUnlimitedStrength();

		byte[] actualChecksum = FileUtil.createChecksum(tempPluginJarFile, "SHA256");
		return StringUtil.toHex(actualChecksum);
	}

	private PluginOperationResult executeInstallFromLocalFile(File pluginJarFile) throws Exception {
		eventBus.post(new PluginInstallExternalEvent(pluginJarFile.getAbsolutePath()));

		PluginInfo pluginInfo = readPluginInfoFromJar(pluginJarFile);

		checkPluginNotInstalled(pluginInfo.getPluginId());
		checkPluginCompatibility(pluginInfo);

		File targetPluginJarFile = installPlugin(pluginJarFile, pluginInfo);

		result.setSourcePluginPath(pluginJarFile.getPath());
		result.setTargetPluginPath(targetPluginJarFile.getPath());
		result.setAffectedPluginInfo(pluginInfo);
		result.setResultCode(PluginResultCode.OK);

		return result;
	}

	private PluginOperationResult executeInstallFromUrl(String downloadJarUrl) throws Exception {
		eventBus.post(new PluginInstallExternalEvent(downloadJarUrl));

		File tempPluginJarFile = downloadPluginJar(downloadJarUrl);
		PluginInfo pluginInfo = readPluginInfoFromJar(tempPluginJarFile);

		checkPluginNotInstalled(pluginInfo.getPluginId());
		checkPluginCompatibility(pluginInfo);

		File targetPluginJarFile = installPlugin(tempPluginJarFile, pluginInfo);

		result.setSourcePluginPath(downloadJarUrl);
		result.setTargetPluginPath(targetPluginJarFile.getPath());
		result.setAffectedPluginInfo(pluginInfo);
		result.setResultCode(PluginResultCode.OK);

		return result;
	}

	private void checkPluginNotInstalled(String pluginId) throws Exception {
		Plugin locallyInstalledPlugin = Plugins.get(pluginId);

		if (locallyInstalledPlugin != null) {
			throw new Exception("Plugin '" + pluginId + "' already installed. Use 'sy plugin remove " + pluginId + "' to uninstall it first.");
		}

		logger.log(Level.INFO, "Plugin '" + pluginId + "' not installed. Okay!");
	}

	private PluginInfo readPluginInfoFromJar(File pluginJarFile) throws Exception {
		try (JarInputStream jarStream = new JarInputStream(new FileInputStream(pluginJarFile))) {
			Manifest jarManifest = jarStream.getManifest();

			if (jarManifest == null) {
				throw new Exception("Given file is not a valid Syncany plugin file (not a JAR file, or no manifest).");
			}

			String pluginId = jarManifest.getMainAttributes().getValue("Plugin-Id");

			if (pluginId == null) {
				throw new Exception("Given file is not a valid Syncany plugin file (no plugin ID in manifest).");
			}

			PluginInfo pluginInfo = new PluginInfo();

			pluginInfo.setPluginId(pluginId);
			pluginInfo.setPluginName(jarManifest.getMainAttributes().getValue("Plugin-Name"));
			pluginInfo.setPluginVersion(jarManifest.getMainAttributes().getValue("Plugin-Version"));
			pluginInfo.setPluginDate(jarManifest.getMainAttributes().getValue("Plugin-Date"));
			pluginInfo.setPluginAppMinVersion(jarManifest.getMainAttributes().getValue("Plugin-App-Min-Version"));
			pluginInfo.setPluginRelease(Boolean.parseBoolean(jarManifest.getMainAttributes().getValue("Plugin-Release")));

			if (jarManifest.getMainAttributes().getValue("Plugin-Conflicts-With") != null) {
				pluginInfo.setConflictingPluginIds(Arrays.asList(jarManifest.getMainAttributes().getValue("Plugin-Conflicts-With")));
			}

			return pluginInfo;
		}
	}

	private File installPlugin(File pluginJarFile, PluginInfo pluginInfo) throws IOException {
		File globalUserPluginDir = UserConfig.getUserPluginLibDir();
		globalUserPluginDir.mkdirs();

		File targetPluginJarFile = new File(globalUserPluginDir, String.format("syncany-plugin-%s-%s.jar", pluginInfo.getPluginId(),
						pluginInfo.getPluginVersion()));

		logger.log(Level.INFO, "Installing plugin from " + pluginJarFile + " to " + targetPluginJarFile + " ...");
		FileUtils.copyFile(pluginJarFile, targetPluginJarFile);

		return targetPluginJarFile;
	}

	/**
	 * Downloads the plugin JAR from the given URL to a temporary
	 * local location.
	 */
	private File downloadPluginJar(String pluginJarUrl) throws Exception {
		URL pluginJarFile = new URL(pluginJarUrl);
		logger.log(Level.INFO, "Querying " + pluginJarFile + " ...");

		URLConnection urlConnection = pluginJarFile.openConnection();
		urlConnection.setConnectTimeout(2000);
		urlConnection.setReadTimeout(2000);

		File tempPluginFile = File.createTempFile("syncany-plugin", "tmp");
		tempPluginFile.deleteOnExit();

		logger.log(Level.INFO, "Downloading to " + tempPluginFile + " ...");
		FileOutputStream tempPluginFileOutputStream = new FileOutputStream(tempPluginFile);
		InputStream remoteJarFileInputStream = urlConnection.getInputStream();

		IOUtils.copy(remoteJarFileInputStream, tempPluginFileOutputStream);

		remoteJarFileInputStream.close();
		tempPluginFileOutputStream.close();

		if (!tempPluginFile.exists() || tempPluginFile.length() == 0) {
			throw new Exception("Downloading plugin file failed, URL was " + pluginJarUrl);
		}

		return tempPluginFile;
	}

	private PluginOperationResult executeList() throws Exception {
		final Version applicationVersion = Version.valueOf(Client.getApplicationVersion());
		Map<String, ExtendedPluginInfo> pluginInfos = new TreeMap<String, ExtendedPluginInfo>();

		// First, list local plugins
		if (options.getListMode() == PluginListMode.ALL || options.getListMode() == PluginListMode.LOCAL) {
			for (PluginInfo localPluginInfo : getLocalList()) {
				if (options.getPluginId() != null && !localPluginInfo.getPluginId().equals(options.getPluginId())) {
					continue;
				}

				// Determine standard plugin information
				ExtendedPluginInfo extendedPluginInfo = new ExtendedPluginInfo();

				extendedPluginInfo.setLocalPluginInfo(localPluginInfo);
				extendedPluginInfo.setInstalled(true);

				// Test if plugin can be uninstalled
				Plugin plugin = Plugins.get(localPluginInfo.getPluginId());
				File pluginJarFile = getJarFile(plugin);
				boolean canUninstall = canUninstall(pluginJarFile);

				extendedPluginInfo.setCanUninstall(canUninstall);

				// Add to list
				pluginInfos.put(localPluginInfo.getPluginId(), extendedPluginInfo);
			}
		}

		// Then, list remote plugins
		if (options.getListMode() == PluginListMode.ALL || options.getListMode() == PluginListMode.REMOTE) {
			for (PluginInfo remotePluginInfo : getRemotePluginInfoList()) {
				if (options.getPluginId() != null && !remotePluginInfo.getPluginId().equals(options.getPluginId())) {
					continue;
				}

				ExtendedPluginInfo extendedPluginInfo = pluginInfos.get(remotePluginInfo.getPluginId());
				boolean localPluginInstalled = extendedPluginInfo != null;

				if (!localPluginInstalled) { // Locally not installed
					extendedPluginInfo = new ExtendedPluginInfo();

					extendedPluginInfo.setInstalled(false);
					extendedPluginInfo.setRemoteAvailable(true);
				}
				else { // Locally also installed
					extendedPluginInfo.setRemoteAvailable(true);

					Version localVersion = Version.valueOf(extendedPluginInfo.getLocalPluginInfo().getPluginVersion());
					Version remoteVersion = Version.valueOf(remotePluginInfo.getPluginVersion());
					Version remoteMinAppVersion = Version.valueOf(remotePluginInfo.getPluginAppMinVersion());

					boolean localVersionOutdated = localVersion.lessThan(remoteVersion);
					boolean applicationVersionCompatible = applicationVersion.greaterThanOrEqualTo(remoteMinAppVersion);
					boolean pluginIsOutdated = localVersionOutdated && applicationVersionCompatible;

					extendedPluginInfo.setOutdated(pluginIsOutdated);
				}

				extendedPluginInfo.setRemotePluginInfo(remotePluginInfo);
				pluginInfos.put(remotePluginInfo.getPluginId(), extendedPluginInfo);
			}
		}

		result.setPluginList(new ArrayList<ExtendedPluginInfo>(pluginInfos.values()));
		result.setResultCode(PluginResultCode.OK);

		return result;
	}

	private List<PluginInfo> getLocalList() {
		List<PluginInfo> localPluginInfos = new ArrayList<PluginInfo>();

		for (Plugin plugin : Plugins.list()) {
			PluginInfo pluginInfo = new PluginInfo();

			pluginInfo.setPluginId(plugin.getId());
			pluginInfo.setPluginName(plugin.getName());
			pluginInfo.setPluginVersion(plugin.getVersion());

			localPluginInfos.add(pluginInfo);
		}

		return localPluginInfos;
	}

	private List<PluginInfo> getRemotePluginInfoList() throws Exception {
		String remoteListStr = getRemoteListStr(null);
		PluginListResponse pluginListResponse = new Persister().read(PluginListResponse.class, remoteListStr);

		return pluginListResponse.getPlugins();
	}

	private PluginInfo getRemotePluginInfo(String pluginId) throws Exception {
		String remoteListStr = getRemoteListStr(pluginId);
		PluginListResponse pluginListResponse = new Persister().read(PluginListResponse.class, remoteListStr);

		if (pluginListResponse.getPlugins().size() > 0) {
			return pluginListResponse.getPlugins().get(0);
		}
		else {
			return null;
		}
	}

	private String getRemoteListStr(String pluginId) throws Exception {
		String appVersion = Client.getApplicationVersion();
		String snapshotsEnabled = (options.isSnapshots()) ? "true" : "false";
		String pluginIdQueryStr = (pluginId != null) ? pluginId : "";
		String osStr = EnvironmentUtil.getOperatingSystemDescription();
		String archStr = EnvironmentUtil.getArchDescription();

		String apiEndpointUrl = (options.getApiEndpoint() != null) ? options.getApiEndpoint() : API_DEFAULT_ENDPOINT_URL;
		URL pluginListUrl = new URL(String.format(API_PLUGIN_LIST_REQUEST_FORMAT, apiEndpointUrl, appVersion, snapshotsEnabled, pluginIdQueryStr, osStr, archStr));

		logger.log(Level.INFO, "Querying " + pluginListUrl + " ...");
		eventBus.post(new ConnectToHostExternalEvent(pluginListUrl.getHost()));

		URLConnection urlConnection = pluginListUrl.openConnection();
		urlConnection.setConnectTimeout(2000);
		urlConnection.setReadTimeout(2000);

		BufferedReader urlStreamReader = new BufferedReader(new InputStreamReader(urlConnection.getInputStream()));
		StringBuilder responseStringBuilder = new StringBuilder();

		String line;
		while ((line = urlStreamReader.readLine()) != null) {
			responseStringBuilder.append(line);
		}

		String responseStr = responseStringBuilder.toString();
		logger.log(Level.INFO, "Response from api.syncany.org: " + responseStr);

		return responseStr;
	}
}