OF-1147: Refactoring of Plugin Management

- Plugin Monitor moved to different class
- Methods that operate on plugin.xml files moved to utilility class
  (and made to work even when plugin is not yet loaded)
- Plugin monitoring now loads plugins more efficiently by:
-- Observing parent/child hierarchy (allowing for one-shot load)
-- allowing for concurrent plugin loading
- Dev plugins are now processed as if they were ordinary plugins
- Plugin canoncical naming is now used throughout the code (fixes
  case sensitivity bug)
parent cc3da295
......@@ -79,6 +79,9 @@
<logger name="org.jivesoftware.openfire.container.PluginManager">
<appender-ref ref="console"/>
</logger>
<logger name="org.jivesoftware.openfire.container.PluginMonitor">
<appender-ref ref="console"/>
</logger>
<!-- OF-506: Jetty INFO messages are generally not useful. Ignore them by default. -->
<logger name="org.eclipse.jetty">
......
/*
* Copyright 2016 IgniteRealtime.org
*
* Licensed 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.jivesoftware.openfire.container;
/**
* An enumeration for license agreement types.
*/
public enum License
{
/**
* Distributed using a commercial license.
*/
commercial,
/**
* Distributed using the GNU Public License (GPL).
*/
gpl,
/**
* Distributed using the Apache license.
*/
apache,
/**
* For internal use at an organization only and is not re-distributed.
*/
internal,
/**
* Distributed under another license agreement not covered by one of the other choices. The license agreement
* should be detailed in a Readme or License file that accompanies the code.
*/
other
}
/**
* $RCSfile$
* $Revision: 3001 $
* $Date: 2005-10-31 05:39:25 -0300 (Mon, 31 Oct 2005) $
*
* Copyright (C) 2004-2008 Jive Software. All rights reserved.
*
* Licensed 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.jivesoftware.openfire.container;
import org.dom4j.Attribute;
import org.dom4j.Document;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;
import org.jivesoftware.admin.AdminConsole;
import org.jivesoftware.database.DbConnectionManager;
import org.jivesoftware.openfire.XMPPServer;
import org.jivesoftware.util.LocaleUtils;
import org.jivesoftware.util.Version;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.DirectoryStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.zip.ZipFile;
/**
* Loads and manages plugins. The <tt>plugins</tt> directory is monitored for any
* new plugins, and they are dynamically loaded.
*
* <p>An instance of this class can be obtained using:</p>
*
* <tt>XMPPServer.getInstance().getPluginManager()</tt>
*
* @author Matt Tucker
* @see Plugin
* @see org.jivesoftware.openfire.XMPPServer#getPluginManager()
*/
public class PluginManager {
private static final Logger Log = LoggerFactory.getLogger(PluginManager.class);
private Path pluginDirectory;
private Map<String, Plugin> plugins;
private Map<Plugin, PluginClassLoader> classloaders;
private Map<Plugin, Path> pluginDirs;
/**
* Keep track of plugin names and their unzipped files. This list is updated when plugin
* is exploded and not when is loaded.
*/
private Map<String, Path> pluginFiles;
private ScheduledExecutorService executor = null;
private Map<Plugin, PluginDevEnvironment> pluginDevelopment;
private Map<Plugin, List<String>> parentPluginMap;
private Map<Plugin, String> childPluginMap;
private Set<String> devPlugins;
private PluginMonitor pluginMonitor;
private Set<PluginListener> pluginListeners = new CopyOnWriteArraySet<>();
private Set<PluginManagerListener> pluginManagerListeners = new CopyOnWriteArraySet<>();
/**
* Constructs a new plugin manager.
*
* @param pluginDir the plugin directory.
*/
public PluginManager(File pluginDir) {
this.pluginDirectory = pluginDir.toPath();
plugins = new TreeMap<>( String.CASE_INSENSITIVE_ORDER );
pluginDirs = new HashMap<>();
pluginFiles = new TreeMap<>( String.CASE_INSENSITIVE_ORDER );
classloaders = new HashMap<>();
pluginDevelopment = new HashMap<>();
parentPluginMap = new HashMap<>();
childPluginMap = new HashMap<>();
devPlugins = new TreeSet<>( String.CASE_INSENSITIVE_ORDER );
pluginMonitor = new PluginMonitor();
}
/**
* Starts plugins and the plugin monitoring service.
*/
public void start() {
executor = new ScheduledThreadPoolExecutor(1);
// See if we're in development mode. If so, check for new plugins once every 5 seconds.
// Otherwise, default to every 20 seconds.
if (Boolean.getBoolean("developmentMode")) {
executor.scheduleWithFixedDelay(pluginMonitor, 0, 5, TimeUnit.SECONDS);
}
else {
executor.scheduleWithFixedDelay(pluginMonitor, 0, 20, TimeUnit.SECONDS);
}
}
/**
* Shuts down all running plugins.
*/
public void shutdown() {
Log.info("Shutting down. Unloading all installed plugins...");
// Stop the plugin monitoring service.
if (executor != null) {
executor.shutdown();
}
// Shutdown all installed plugins.
for (Map.Entry<String, Plugin> plugin : plugins.entrySet()) {
try {
plugin.getValue().destroyPlugin();
Log.info("Unloaded plugin '{}'.", plugin.getKey());
}
catch (Exception e) {
Log.error("An exception occurred while trying to unload plugin '{}':", plugin.getKey(), e);
}
}
plugins.clear();
pluginDirs.clear();
pluginFiles.clear();
classloaders.clear();
pluginDevelopment.clear();
childPluginMap.clear();
pluginMonitor = null;
}
/**
* Installs or updates an existing plugin.
*
* @param in the input stream that contains the new plugin definition.
* @param pluginFilename the filename of the plugin to create or update.
* @return true if the plugin was successfully installed or updated.
*/
public boolean installPlugin(InputStream in, String pluginFilename) {
if ( pluginFilename == null || pluginFilename.isEmpty()) {
Log.error("Error installing plugin: pluginFilename was null or empty.");
return false;
}
if (in == null) {
Log.error("Error installing plugin '{}': Input stream was null.", pluginFilename);
return false;
}
try {
// If pluginFilename is a path instead of a simple file name, we only want the file name
int index = pluginFilename.lastIndexOf(File.separator);
if (index != -1) {
pluginFilename = pluginFilename.substring(index+1);
}
// Absolute path to the plugin file
Path absolutePath = pluginDirectory.resolve(pluginFilename);
Path partFile = pluginDirectory.resolve(pluginFilename + ".part");
// Save input stream contents to a temp file
Files.copy(in, partFile, StandardCopyOption.REPLACE_EXISTING);
// Rename temp file to .jar
Files.move(partFile, absolutePath, StandardCopyOption.REPLACE_EXISTING);
// Ask the plugin monitor to update the plugin immediately.
pluginMonitor.run();
}
catch (IOException e) {
Log.error("An exception occurred while installing new version of plugin '{}':", pluginFilename, e);
return false;
}
return true;
}
/**
* Returns true if the specified filename, that belongs to a plugin, exists.
*
* @param pluginFilename the filename of the plugin to create or update.
* @return true if the specified filename, that belongs to a plugin, exists.
*/
public boolean isPluginDownloaded(String pluginFilename) {
return Files.exists(pluginDirectory.resolve(pluginFilename));
}
/**
* Returns a Collection of all installed plugins.
*
* @return a Collection of all installed plugins.
*/
public Collection<Plugin> getPlugins() {
return Collections.unmodifiableCollection(Arrays.asList( plugins.values().toArray( new Plugin[ plugins.size() ]) ));
}
/**
* Returns a plugin by name or <tt>null</tt> if a plugin with that name does not
* exist. The name is the name of the directory that the plugin is in such as
* "broadcast".
*
* @param name the name of the plugin.
* @return the plugin.
*/
public Plugin getPlugin(String name) {
return plugins.get(name);
}
/**
* Returns the plugin's directory.
*
* @param plugin the plugin.
* @return the plugin's directory.
*/
public File getPluginDirectory(Plugin plugin) {
return pluginDirs.get(plugin).toFile();
}
/**
* Returns the JAR or WAR file that created the plugin.
*
* @param name the name of the plugin.
* @return the plugin JAR or WAR file.
*/
public File getPluginFile(String name) {
return pluginFiles.get(name).toFile();
}
/**
* Returns true if at least one attempt to load plugins has been done. A true value does not mean
* that available plugins have been loaded nor that plugins to be added in the future are already
* loaded. :)<p>
*
* TODO Current version does not consider child plugins that may be loaded in a second attempt. It either
* TODO consider plugins that were found but failed to be loaded due to some error.
*
* @return true if at least one attempt to load plugins has been done.
*/
public boolean isExecuted() {
return pluginMonitor.executed;
}
/**
* Loads a plug-in module into the container. Loading consists of the
* following steps:<ul>
* <p/>
* <li>Add all jars in the <tt>lib</tt> dir (if it exists) to the class loader</li>
* <li>Add all files in <tt>classes</tt> dir (if it exists) to the class loader</li>
* <li>Locate and load <tt>module.xml</tt> into the context</li>
* <li>For each jive.module entry, load the given class as a module and start it</li>
* <p/>
* </ul>
*
* @param pluginDir the plugin directory.
*/
private void loadPlugin(Path pluginDir) {
// Only load the admin plugin during setup mode.
if (XMPPServer.getInstance().isSetupMode() && !(pluginDir.getFileName().toString().equals("admin"))) {
return;
}
String pluginName = pluginDir.getFileName().toString();
Log.debug("Loading plugin '{}'...", pluginName);
Plugin plugin;
try {
Path pluginConfig = pluginDir.resolve("plugin.xml");
if (Files.exists(pluginConfig)) {
SAXReader saxReader = new SAXReader();
saxReader.setEncoding("UTF-8");
Document pluginXML = saxReader.read(pluginConfig.toFile());
// See if the plugin specifies a version of Openfire
// required to run.
Element minServerVersion = (Element)pluginXML.selectSingleNode("/plugin/minServerVersion");
if (minServerVersion != null) {
Version requiredVersion = new Version(minServerVersion.getTextTrim());
Version currentVersion = XMPPServer.getInstance().getServerInfo().getVersion();
if (requiredVersion.isNewerThan(currentVersion)) {
Log.warn("Ignoring plugin '{}': requires server version {}. Current server version is {}.", pluginName, requiredVersion, currentVersion);
return;
}
}
PluginClassLoader pluginLoader;
// Check to see if this is a child plugin of another plugin. If it is, we
// re-use the parent plugin's class loader so that the plugins can interact.
Element parentPluginNode = (Element)pluginXML.selectSingleNode("/plugin/parentPlugin");
String webRootKey = pluginName + ".webRoot";
String classesDirKey = pluginName + ".classes";
String webRoot = System.getProperty(webRootKey);
String classesDir = System.getProperty(classesDirKey);
if (webRoot != null) {
final Path compilationClassesDir = pluginDir.resolve("classes");
if (Files.notExists(compilationClassesDir)) {
Files.createDirectory(compilationClassesDir);
}
compilationClassesDir.toFile().deleteOnExit();
}
if (parentPluginNode != null) {
String parentPlugin = parentPluginNode.getTextTrim();
// See if the parent is already loaded.
if (plugins.containsKey(parentPlugin)) {
pluginLoader = classloaders.get(getPlugin(parentPlugin));
pluginLoader.addDirectory(pluginDir.toFile(), classesDir != null);
}
else {
// See if the parent plugin exists but just hasn't been loaded yet.
final Set<String> plugins = new TreeSet<>( String.CASE_INSENSITIVE_ORDER );
plugins.addAll( Arrays.asList( pluginDir.getParent().toFile().list() ) );
if ( plugins.contains( parentPlugin + ".jar" ) || plugins.contains( parentPlugin + ".war" ) ) {
// Silently return. The child plugin will get loaded up on the next
// plugin load run after the parent.
return;
} else {
Log.warn("Ignoring plugin '{}': parent plugin '{}' not present.", pluginName, parentPlugin);
return;
}
}
}
// This is not a child plugin, so create a new class loader.
else {
pluginLoader = new PluginClassLoader();
pluginLoader.addDirectory(pluginDir.toFile(), classesDir != null);
}
// Check to see if development mode is turned on for the plugin. If it is,
// configure dev mode.
PluginDevEnvironment dev = null;
if (webRoot != null || classesDir != null) {
dev = new PluginDevEnvironment();
Log.info("Plugin '{}' is running in development mode.", pluginName);
if (webRoot != null) {
Path webRootDir = Paths.get(webRoot);
if (Files.notExists(webRootDir)) {
// Ok, let's try it relative from this plugin dir?
webRootDir = pluginDir.resolve(webRoot);
}
if (Files.exists(webRootDir)) {
dev.setWebRoot(webRootDir.toFile());
}
}
if (classesDir != null) {
Path classes = Paths.get(classesDir);
if (Files.notExists(classes)) {
// ok, let's try it relative from this plugin dir?
classes = pluginDir.resolve(classesDir);
}
if (Files.exists(classes)) {
dev.setClassesDir(classes.toFile());
pluginLoader.addURLFile(classes.toUri().toURL());
}
}
}
String className = pluginXML.selectSingleNode("/plugin/class").getText().trim();
plugin = (Plugin)pluginLoader.loadClass(className).newInstance();
if (parentPluginNode != null) {
String parentPlugin = parentPluginNode.getTextTrim();
// See if the parent is already loaded.
if (plugins.containsKey(parentPlugin)) {
pluginLoader = classloaders.get(getPlugin(parentPlugin));
classloaders.put(plugin, pluginLoader);
}
}
plugins.put(pluginName, plugin);
pluginDirs.put(plugin, pluginDir);
// If this is a child plugin, register it as such.
if (parentPluginNode != null) {
String parentPlugin = parentPluginNode.getTextTrim();
// The name of the parent plugin as specified in plugin.xml might have incorrect casing. Lookup the correct name.
for (Map.Entry<String, Plugin> entry : plugins.entrySet() ) {
if ( entry.getKey().equalsIgnoreCase( parentPlugin ) ) {
parentPlugin = entry.getKey();
break;
}
}
List<String> childrenPlugins = parentPluginMap.get(plugins.get(parentPlugin));
if (childrenPlugins == null) {
childrenPlugins = new ArrayList<>();
parentPluginMap.put(plugins.get(parentPlugin), childrenPlugins);
}
childrenPlugins.add(pluginName);
// Also register child to parent relationship.
childPluginMap.put(plugin, parentPlugin);
}
else {
// Only register the class loader in the case of this not being
// a child plugin.
classloaders.put(plugin, pluginLoader);
}
// Check the plugin's database schema (if it requires one).
if (!DbConnectionManager.getSchemaManager().checkPluginSchema(plugin)) {
// The schema was not there and auto-upgrade failed.
Log.error("Error while loading plugin '{}': {}", pluginName, LocaleUtils.getLocalizedString("upgrade.database.failure"));
}
// Load any JSP's defined by the plugin.
Path webXML = pluginDir.resolve("web").resolve("WEB-INF").resolve("web.xml");
if (Files.exists(webXML)) {
PluginServlet.registerServlets(this, plugin, webXML.toFile());
}
// Load any custom-defined servlets.
Path customWebXML = pluginDir.resolve("web").resolve("WEB-INF").resolve("web-custom.xml");
if (Files.exists(customWebXML)) {
PluginServlet.registerServlets(this, plugin, customWebXML.toFile());
}
if (dev != null) {
pluginDevelopment.put(plugin, dev);
}
// Configure caches of the plugin
configureCaches(pluginDir, pluginName);
// Init the plugin.
ClassLoader oldLoader = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(pluginLoader);
plugin.initializePlugin(this, pluginDir.toFile());
Log.debug("Initialized plugin '{}'.", pluginName);
Thread.currentThread().setContextClassLoader(oldLoader);
// If there a <adminconsole> section defined, register it.
Element adminElement = (Element)pluginXML.selectSingleNode("/plugin/adminconsole");
if (adminElement != null) {
Element appName = (Element)adminElement.selectSingleNode(
"/plugin/adminconsole/global/appname");
if (appName != null) {
// Set the plugin name so that the proper i18n String can be loaded.
appName.addAttribute("plugin", pluginName);
}
// If global images are specified, override their URL.
Element imageEl = (Element)adminElement.selectSingleNode(
"/plugin/adminconsole/global/logo-image");
if (imageEl != null) {
imageEl.setText("plugins/" + pluginName + "/" + imageEl.getText());
// Set the plugin name so that the proper i18n String can be loaded.
imageEl.addAttribute("plugin", pluginName);
}
imageEl = (Element)adminElement.selectSingleNode("/plugin/adminconsole/global/login-image");
if (imageEl != null) {
imageEl.setText("plugins/" + pluginName + "/" + imageEl.getText());
// Set the plugin name so that the proper i18n String can be loaded.
imageEl.addAttribute("plugin", pluginName);
}
// Modify all the URL's in the XML so that they are passed through
// the plugin servlet correctly.
List urls = adminElement.selectNodes("//@url");
for (Object url : urls) {
Attribute attr = (Attribute)url;
attr.setValue("plugins/" + pluginName + "/" + attr.getValue());
}
// In order to internationalize the names and descriptions in the model,
// we add a "plugin" attribute to each tab, sidebar, and item so that
// the the renderer knows where to load the i18n Strings from.
String[] elementNames = new String [] { "tab", "sidebar", "item" };
for (String elementName : elementNames) {
List values = adminElement.selectNodes("//" + elementName);
for (Object value : values) {
Element element = (Element) value;
// Make sure there's a name or description. Otherwise, no need to
// override i18n settings.
if (element.attribute("name") != null ||
element.attribute("value") != null) {
element.addAttribute("plugin", pluginName);
}
}
}
AdminConsole.addModel(pluginName, adminElement);
}
firePluginCreatedEvent(pluginName, plugin);
}
else {
Log.warn("Plugin '{}' could not be loaded: no plugin.xml file found.", pluginName);
}
}
catch (Throwable e) {
Log.error("An exception occurred while loading plugin '{}':", pluginName, e);
}
Log.info( "Successfully loaded plugin '{}'.", pluginName );
}
private void configureCaches(Path pluginDir, String pluginName) {
Path cacheConfig = pluginDir.resolve("cache-config.xml");
if (Files.exists(cacheConfig)) {
PluginCacheConfigurator configurator = new PluginCacheConfigurator();
try {
configurator.setInputStream(new BufferedInputStream(Files.newInputStream(cacheConfig)));
configurator.configure(pluginName);
}
catch (Exception e) {
Log.error("An exception occurred while trying to configure caches for plugin '{}':", pluginName, e);
}
}
}
private void firePluginCreatedEvent(String name, Plugin plugin) {
for(PluginListener listener : pluginListeners) {
listener.pluginCreated(name, plugin);
}
}
private void firePluginsMonitored() {
for(PluginManagerListener listener : pluginManagerListeners) {
listener.pluginsMonitored();
}
}
/**
* Unloads a plugin. The {@link Plugin#destroyPlugin()} method will be called and then
* any resources will be released. The name should be the name of the plugin directory
* and not the name as given by the plugin meta-data. This method only removes
* the plugin but does not delete the plugin JAR file. Therefore, if the plugin JAR
* still exists after this method is called, the plugin will be started again the next
* time the plugin monitor process runs. This is useful for "restarting" plugins.
* <p>
* This method is called automatically when a plugin's JAR file is deleted.
* </p>
*
* @param pluginName the name of the plugin to unload.
*/
public void unloadPlugin(String pluginName) {
Log.debug("Unloading plugin '{}'...",pluginName);
Plugin plugin = plugins.get(pluginName);
if (plugin != null) {
// Remove from dev mode if it exists.
pluginDevelopment.remove(plugin);
// See if any child plugins are defined.
if (parentPluginMap.containsKey(plugin)) {
String[] childPlugins =
parentPluginMap.get(plugin).toArray(new String[parentPluginMap.get(plugin).size()]);
parentPluginMap.remove(plugin);
for (String childPlugin : childPlugins) {
Log.debug("Unloading child plugin: '{}'.", childPlugin);
childPluginMap.remove(plugins.get(childPlugin));
unloadPlugin(childPlugin);
}
}
Path webXML = pluginDirectory.resolve(pluginName).resolve("web").resolve("WEB-INF").resolve("web.xml");
if (Files.exists(webXML)) {
AdminConsole.removeModel(pluginName);
PluginServlet.unregisterServlets(webXML.toFile());
}
Path customWebXML = pluginDirectory.resolve(pluginName).resolve("web").resolve("WEB-INF").resolve("web-custom.xml");
if (Files.exists(customWebXML)) {
PluginServlet.unregisterServlets(customWebXML.toFile());
}
// Wrap destroying the plugin in a try/catch block. Otherwise, an exception raised
// in the destroy plugin process will disrupt the whole unloading process. It's still
// possible that classloader destruction won't work in the case that destroying the plugin
// fails. In that case, Openfire may need to be restarted to fully cleanup the plugin
// resources.
try {
plugin.destroyPlugin();
Log.debug( "Destroyed plugin '{}'.", pluginName );
}
catch (Exception e) {
Log.error( "An exception occurred while unloading plugin '{}':", pluginName, e);
}
}
// Remove references to the plugin so it can be unloaded from memory
// If plugin still fails to be removed then we will add references back
// Anyway, for a few seconds admins may not see the plugin in the admin console
// and in a subsequent refresh it will appear if failed to be removed
plugins.remove(pluginName);
Path pluginFile = pluginDirs.remove(plugin);
PluginClassLoader pluginLoader = classloaders.remove(plugin);
// try to close the cached jar files from the plugin class loader
if (pluginLoader != null) {
pluginLoader.unloadJarFiles();
} else {
Log.warn("No plugin loader found for '{}'.",pluginName);
}
// Try to remove the folder where the plugin was exploded. If this works then
// the plugin was successfully removed. Otherwise, some objects created by the
// plugin are still in memory.
Path dir = pluginDirectory.resolve(pluginName);
// Give the plugin 2 seconds to unload.
try {
Thread.sleep(2000);
// Ask the system to clean up references.
System.gc();
int count = 0;
while (!deleteDir(dir) && count++ < 5) {
Log.warn("Error unloading plugin '{}'. Will attempt again momentarily.", pluginName);
Thread.sleep( 8000 );
// Ask the system to clean up references.
System.gc();
}
} catch (InterruptedException e) {
Log.debug( "Stopped waiting for plugin '{}' to be fully unloaded.", pluginName, e );
}
if (plugin != null && Files.notExists(dir)) {
// Unregister plugin caches
PluginCacheRegistry.getInstance().unregisterCaches(pluginName);
// See if this is a child plugin. If it is, we should unload
// the parent plugin as well.
if (childPluginMap.containsKey(plugin)) {
String parentPluginName = childPluginMap.get(plugin);
Plugin parentPlugin = plugins.get(parentPluginName);
List<String> childrenPlugins = parentPluginMap.get(parentPlugin);
childrenPlugins.remove(pluginName);
childPluginMap.remove(plugin);
// When the parent plugin implements PluginListener, its pluginDestroyed() method
// isn't called if it dies first before its child. Athough the parent will die anyway,
// it's proper if the parent "gets informed first" about the dying child when the
// child is the one being killed first.
if (parentPlugin instanceof PluginListener) {
PluginListener listener;
listener = (PluginListener) parentPlugin;
listener.pluginDestroyed(pluginName, plugin);
}
unloadPlugin(parentPluginName);
}
firePluginDestroyedEvent(pluginName, plugin);
Log.info("Successfully unloaded plugin '{}'.", pluginName);
}
else if (plugin != null) {
Log.info("Restore references since we failed to remove the plugin '{}'.", pluginName);
plugins.put(pluginName, plugin);
pluginDirs.put(plugin, pluginFile);
classloaders.put(plugin, pluginLoader);
}
}
private void firePluginDestroyedEvent(String name, Plugin plugin) {
for (PluginListener listener : pluginListeners) {
listener.pluginDestroyed(name, plugin);
}
}
/**
* Loads a class from the classloader of a plugin.
*
* @param plugin the plugin.
* @param className the name of the class to load.
* @return the class.
* @throws ClassNotFoundException if the class was not found.
* @throws IllegalAccessException if not allowed to access the class.
* @throws InstantiationException if the class could not be created.
*/
public Class loadClass(Plugin plugin, String className) throws ClassNotFoundException,
IllegalAccessException, InstantiationException {
PluginClassLoader loader = classloaders.get(plugin);
return loader.loadClass(className);
}
/**
* Returns a plugin's dev environment if development mode is enabled for
* the plugin.
*
* @param plugin the plugin.
* @return the plugin dev environment, or <tt>null</tt> if development
* mode is not enabled for the plugin.
*/
public PluginDevEnvironment getDevEnvironment(Plugin plugin) {
return pluginDevelopment.get(plugin);
}
/**
* Returns the name of a plugin. The value is retrieved from the plugin.xml file
* of the plugin. If the value could not be found, <tt>null</tt> will be returned.
* Note that this value is distinct from the name of the plugin directory.
*
* @param plugin the plugin.
* @return the plugin's name.
*/
public String getName(Plugin plugin) {
String name = getElementValue(plugin, "/plugin/name");
String pluginName = pluginDirs.get(plugin).getFileName().toString();
if (name != null) {
return AdminConsole.getAdminText(name, pluginName);
}
else {
return pluginName;
}
}
/**
* Returns the description of a plugin. The value is retrieved from the plugin.xml file
* of the plugin. If the value could not be found, <tt>null</tt> will be returned.
*
* @param plugin the plugin.
* @return the plugin's description.
*/
public String getDescription(Plugin plugin) {
String pluginName = pluginDirs.get(plugin).getFileName().toString();
return AdminConsole.getAdminText(getElementValue(plugin, "/plugin/description"), pluginName);
}
/**
* Returns the author of a plugin. The value is retrieved from the plugin.xml file
* of the plugin. If the value could not be found, <tt>null</tt> will be returned.
*
* @param plugin the plugin.
* @return the plugin's author.
*/
public String getAuthor(Plugin plugin) {
return getElementValue(plugin, "/plugin/author");
}
/**
* Returns the version of a plugin. The value is retrieved from the plugin.xml file
* of the plugin. If the value could not be found, <tt>null</tt> will be returned.
*
* @param plugin the plugin.
* @return the plugin's version.
*/
public String getVersion(Plugin plugin) {
return getElementValue(plugin, "/plugin/version");
}
/**
* Returns the minimum server version this plugin can run within. The value is retrieved from the plugin.xml file
* of the plugin. If the value could not be found, <tt>null</tt> will be returned.
*
* @param plugin the plugin.
* @return the plugin's version.
*/
public String getMinServerVersion(Plugin plugin) {
return getElementValue(plugin, "/plugin/minServerVersion");
}
/**
* Returns the database schema key of a plugin, if it exists. The value is retrieved
* from the plugin.xml file of the plugin. If the value could not be found, <tt>null</tt>
* will be returned.
*
* @param plugin the plugin.
* @return the plugin's database schema key or <tt>null</tt> if it doesn't exist.
*/
public String getDatabaseKey(Plugin plugin) {
return getElementValue(plugin, "/plugin/databaseKey");
}
/**
* Returns the database schema version of a plugin, if it exists. The value is retrieved
* from the plugin.xml file of the plugin. If the value could not be found, <tt>-1</tt>
* will be returned.
*
* @param plugin the plugin.
* @return the plugin's database schema version or <tt>-1</tt> if it doesn't exist.
*/
public int getDatabaseVersion(Plugin plugin) {
String versionString = getElementValue(plugin, "/plugin/databaseVersion");
if (versionString != null) {
try {
return Integer.parseInt(versionString.trim());
}
catch (NumberFormatException nfe) {
Log.error("Unable to parse the database version for plugin '{}'.", getName( plugin ), nfe);
}
}
return -1;
}
/**
* Returns the license agreement type that the plugin is governed by. The value
* is retrieved from the plugin.xml file of the plugin. If the value could not be
* found, {@link License#other} is returned.
*
* @param plugin the plugin.
* @return the plugin's license agreement.
*/
public License getLicense(Plugin plugin) {
String licenseString = getElementValue(plugin, "/plugin/licenseType");
if (licenseString != null) {
try {
// Attempt to load the get the license type. We lower-case and
// trim the license type to give plugin author's a break. If the
// license type is not recognized, we'll log the error and default
// to "other".
return License.valueOf(licenseString.toLowerCase().trim());
}
catch (IllegalArgumentException iae) {
Log.error("Unrecognized license type '{}' for plugin '{}'.", licenseString.toLowerCase().trim(), getName( plugin ), iae);
}
}
return License.other;
}
/**
* Returns the classloader of a plugin.
*
* @param plugin the plugin.
* @return the classloader of the plugin.
*/
public PluginClassLoader getPluginClassloader(Plugin plugin) {
return classloaders.get(plugin);
}
/**
* Returns the value of an element selected via an xpath expression from
* a Plugin's plugin.xml file.
*
* @param plugin the plugin.
* @param xpath the xpath expression.
* @return the value of the element selected by the xpath expression.
*/
private String getElementValue(Plugin plugin, String xpath) {
Path pluginDir = pluginDirs.get(plugin);
if (pluginDir == null) {
return null;
}
try {
Path pluginConfig = pluginDir.resolve("plugin.xml");
if (Files.exists(pluginConfig)) {
SAXReader saxReader = new SAXReader();
saxReader.setEncoding("UTF-8");
Document pluginXML = saxReader.read(pluginConfig.toFile());
Element element = (Element)pluginXML.selectSingleNode(xpath);
if (element != null) {
return element.getTextTrim();
}
}
}
catch (Exception e) {
Log.error("Unable to get element value '{}' from plugin.xml of plugin '{}':", xpath, getName(plugin), e);
}
return null;
}
/**
* An enumberation for plugin license agreement types.
*/
@SuppressWarnings({"UnnecessarySemicolon"}) // Support for QDox Parser
public enum License {
/**
* The plugin is distributed using a commercial license.
*/
commercial,
/**
* The plugin is distributed using the GNU Public License (GPL).
*/
gpl,
/**
* The plugin is distributed using the Apache license.
*/
apache,
/**
* The plugin is for internal use at an organization only and is not re-distributed.
*/
internal,
/**
* The plugin is distributed under another license agreement not covered by
* one of the other choices. The license agreement should be detailed in the
* plugin Readme.
*/
other;
}
/**
* A service that monitors the plugin directory for plugins. It periodically
* checks for new plugin JAR files and extracts them if they haven't already
* been extracted. Then, any new plugin directories are loaded.
*/
private class PluginMonitor implements Runnable {
/**
* Tracks if the monitor is currently running.
*/
private boolean running = false;
/**
* True if the monitor has been executed at least once. After the first iteration in {@link #run}
* this variable will always be true.
* */
private boolean executed = false;
/**
* True when it's the first time the plugin monitor process runs. This is helpful for
* bootstrapping purposes.
*/
private boolean firstRun = true;
@Override
public void run() {
// If the task is already running, return.
synchronized (this) {
if (running) {
return;
}
running = true;
}
try {
running = true;
// Look for extra plugin directories specified as a system property.
String pluginDirs = System.getProperty("pluginDirs");
if (pluginDirs != null) {
StringTokenizer st = new StringTokenizer(pluginDirs, ", ");
while (st.hasMoreTokens()) {
String dir = st.nextToken();
if (!devPlugins.contains(dir)) {
loadPlugin(Paths.get(dir));
devPlugins.add(dir);
}
}
}
// Turn the list of JAR/WAR files into a set so that we can do lookups.
Set<String> jarSet = new HashSet<>();
try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(pluginDirectory, new DirectoryStream.Filter<Path>() {
@Override
public boolean accept(Path pathname) throws IOException {
String fileName = pathname.getFileName().toString().toLowerCase();
return (fileName.endsWith(".jar") || fileName.endsWith(".war"));
}
})) {
for (Path jarFile : directoryStream) {
jarSet.add(jarFile.getFileName().toString().toLowerCase());
String pluginName = jarFile.getFileName().toString().substring(0,
jarFile.getFileName().toString().length() - 4).toLowerCase();
// See if the JAR has already been exploded.
Path dir = pluginDirectory.resolve(pluginName);
// Store the JAR/WAR file that created the plugin folder
pluginFiles.put(pluginName, jarFile);
// If the JAR hasn't been exploded, do so.
if (Files.notExists(dir)) {
unzipPlugin(pluginName, jarFile, dir);
}
// See if the JAR is newer than the directory. If so, the plugin
// needs to be unloaded and then reloaded.
else if (Files.getLastModifiedTime(jarFile).toMillis() > Files.getLastModifiedTime(dir).toMillis()) {
// If this is the first time that the monitor process is running, then
// plugins won't be loaded yet. Therefore, just delete the directory.
if (firstRun) {
int count = 0;
// Attempt to delete the folder for up to 5 seconds.
while (!deleteDir(dir) && count++ < 5) {
Thread.sleep(1000);
}
}
else {
unloadPlugin(pluginName);
}
// If the delete operation was a success, unzip the plugin.
if (Files.notExists(dir)) {
unzipPlugin(pluginName, jarFile, dir);
}
}
}
}
List<Path> dirs = new ArrayList<>();
try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(pluginDirectory, new DirectoryStream.Filter<Path>() {
@Override
public boolean accept(Path pathname) throws IOException {
return Files.isDirectory(pathname);
}
})) {
for (Path path : directoryStream) {
dirs.add(path);
}
}
// Sort the list of directories so that the "admin" plugin is always
// first in the list.
Collections.sort(dirs, new Comparator<Path>() {
public int compare(Path file1, Path file2) {
if (file1.getFileName().toString().equals("admin")) {
return -1;
} else if (file2.getFileName().toString().equals("admin")) {
return 1;
} else {
return file1.compareTo(file2);
}
}
});
// See if any currently running plugins need to be unloaded
// due to the JAR file being deleted (ignore admin plugin).
// Build a list of plugins to delete first so that the plugins
// keyset isn't modified as we're iterating through it.
List<String> toDelete = new ArrayList<>();
for (Path pluginDir : dirs) {
String pluginName = pluginDir.getFileName().toString();
if (pluginName.equals("admin")) {
continue;
}
if (!jarSet.contains(pluginName + ".jar")) {
if (!jarSet.contains(pluginName + ".war")) {
Log.info( "Plugin '{}' was removed from the file system.", pluginName);
toDelete.add(pluginName);
}
}
}
for (String pluginName : toDelete) {
unloadPlugin(pluginName);
}
// Load all plugins that need to be loaded.
boolean somethingChanged = false;
for (Path dirFile : dirs) {
// If the plugin hasn't already been started, start it.
if (Files.exists(dirFile) && !plugins.containsKey(dirFile.getFileName().toString())) {
somethingChanged = true;
loadPlugin(dirFile);
}
}
if ( somethingChanged ) {
Log.info( "Finished processing all plugins." );
}
// Set that at least one iteration was done. That means that "all available" plugins
// have been loaded by now.
if (!XMPPServer.getInstance().isSetupMode()) {
executed = true;
}
// Trigger event that plugins have been monitored
firePluginsMonitored();
}
catch (Throwable e) {
Log.error("An unexpected exception occurred:", e);
}
// Finished running task.
synchronized (this) {
running = false;
}
// Process finished, so set firstRun to false (setting it multiple times doesn't hurt).
firstRun = false;
}
/**
* Unzips a plugin from a JAR file into a directory. If the JAR file
* isn't a plugin, this method will do nothing.
*
* @param pluginName the name of the plugin.
* @param file the JAR file
* @param dir the directory to extract the plugin to.
*/
private void unzipPlugin(String pluginName, Path file, Path dir) {
try (ZipFile zipFile = new JarFile(file.toFile())) {
// Ensure that this JAR is a plugin.
if (zipFile.getEntry("plugin.xml") == null) {
return;
}
Files.createDirectory(dir);
// Set the date of the JAR file to the newly created folder
Files.setLastModifiedTime(dir, Files.getLastModifiedTime(file));
Log.debug("Extracting plugin '{}'...", pluginName);
for (Enumeration e = zipFile.entries(); e.hasMoreElements();) {
JarEntry entry = (JarEntry)e.nextElement();
Path entryFile = dir.resolve(entry.getName());
// Ignore any manifest.mf entries.
if (entry.getName().toLowerCase().endsWith("manifest.mf")) {
continue;
}
if (!entry.isDirectory()) {
Files.createDirectories(entryFile.getParent());
try (InputStream zin = zipFile.getInputStream(entry)) {
Files.copy(zin, entryFile, StandardCopyOption.REPLACE_EXISTING);
}
}
}
Log.debug("Successfully extracted plugin '{}'.", pluginName);
}
catch (Exception e) {
Log.error("An exception occurred while trying to extract plugin '{}':", pluginName, e);
}
}
}
/**
* Deletes a directory.
*
* @param dir the directory to delete.
* @return true if the directory was deleted.
*/
private boolean deleteDir(Path dir) {
try {
if (Files.isDirectory(dir)) {
Files.walkFileTree(dir, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
try {
Files.deleteIfExists(file);
} catch (IOException e) {
Log.debug("Plugin removal: could not delete: {}", file);
throw e;
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
try {
Files.deleteIfExists(dir);
} catch (IOException e) {
Log.debug("Plugin removal: could not delete: {}", dir);
throw e;
}
return FileVisitResult.CONTINUE;
}
});
}
boolean deleted = Files.notExists(dir) || Files.deleteIfExists(dir);
if (deleted) {
// Remove the JAR/WAR file that created the plugin folder
pluginFiles.remove(dir.getFileName().toString());
}
return deleted;
} catch (IOException e) {
return Files.notExists(dir);
}
}
public void addPluginListener(PluginListener listener) {
pluginListeners.add(listener);
}
public void removePluginListener(PluginListener listener) {
pluginListeners.remove(listener);
}
public void addPluginManagerListener(PluginManagerListener listener) {
pluginManagerListeners.add(listener);
if (isExecuted()) {
firePluginsMonitored();
}
}
public void removePluginManagerListener(PluginManagerListener listener) {
pluginManagerListeners.remove(listener);
}
/**
* $RCSfile$
* $Revision: 3001 $
* $Date: 2005-10-31 05:39:25 -0300 (Mon, 31 Oct 2005) $
*
* Copyright (C) 2004-2008 Jive Software. All rights reserved.
*
* Licensed 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.jivesoftware.openfire.container;
import org.dom4j.Attribute;
import org.dom4j.Document;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;
import org.jivesoftware.admin.AdminConsole;
import org.jivesoftware.database.DbConnectionManager;
import org.jivesoftware.openfire.XMPPServer;
import org.jivesoftware.util.LocaleUtils;
import org.jivesoftware.util.Version;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.*;
import java.util.concurrent.CopyOnWriteArraySet;
/**
* Loads and manages plugins. The <tt>plugins</tt> directory is monitored for any
* new plugins, and they are dynamically loaded.
*
* <p>An instance of this class can be obtained using:</p>
*
* <tt>XMPPServer.getInstance().getPluginManager()</tt>
*
* @author Matt Tucker
* @see Plugin
* @see org.jivesoftware.openfire.XMPPServer#getPluginManager()
*/
public class PluginManager
{
private static final Logger Log = LoggerFactory.getLogger( PluginManager.class );
private final Path pluginDirectory;
private final Map<String, Plugin> plugins = new TreeMap<>( String.CASE_INSENSITIVE_ORDER );
private final Map<Plugin, PluginClassLoader> classloaders = new HashMap<>();
private final Map<Plugin, Path> pluginDirs = new HashMap<>();
private final Map<Plugin, PluginDevEnvironment> pluginDevelopment = new HashMap<>();
private final Map<Plugin, List<String>> parentPluginMap = new HashMap<>();
private final Map<Plugin, String> childPluginMap = new HashMap<>();
private final Set<PluginListener> pluginListeners = new CopyOnWriteArraySet<>();
private final Set<PluginManagerListener> pluginManagerListeners = new CopyOnWriteArraySet<>();
private final PluginMonitor pluginMonitor;
private boolean executed = false;
/**
* Constructs a new plugin manager.
*
* @param pluginDir the directory containing all Openfire plugins, typically OPENFIRE_HOME/plugins/
*/
public PluginManager( File pluginDir )
{
this.pluginDirectory = pluginDir.toPath();
pluginMonitor = new PluginMonitor( this );
}
/**
* Starts plugins and the plugin monitoring service.
*/
public synchronized void start()
{
pluginMonitor.start();
}
/**
* Shuts down all running plugins.
*/
public synchronized void shutdown()
{
Log.info( "Shutting down. Unloading all installed plugins..." );
// Stop the plugin monitoring service.
pluginMonitor.stop();
// Shutdown all installed plugins.
for ( Map.Entry<String, Plugin> plugin : plugins.entrySet() )
{
try
{
plugin.getValue().destroyPlugin();
Log.info( "Unloaded plugin '{}'.", plugin.getKey() );
}
catch ( Exception e )
{
Log.error( "An exception occurred while trying to unload plugin '{}':", plugin.getKey(), e );
}
}
plugins.clear();
pluginDirs.clear();
classloaders.clear();
pluginDevelopment.clear();
childPluginMap.clear();
}
/**
* Returns the directory that contains all plugins. This typically is OPENFIRE_HOME/plugins.
*
* @return The directory that contains all plugins.
*/
public Path getPluginsDirectory()
{
return pluginDirectory;
}
/**
* Installs or updates an existing plugin.
*
* @param in the input stream that contains the new plugin definition.
* @param pluginFilename the filename of the plugin to create or update.
* @return true if the plugin was successfully installed or updated.
*/
public boolean installPlugin( InputStream in, String pluginFilename )
{
if ( pluginFilename == null || pluginFilename.isEmpty() )
{
Log.error( "Error installing plugin: pluginFilename was null or empty." );
return false;
}
if ( in == null )
{
Log.error( "Error installing plugin '{}': Input stream was null.", pluginFilename );
return false;
}
try
{
// If pluginFilename is a path instead of a simple file name, we only want the file name
int index = pluginFilename.lastIndexOf( File.separator );
if ( index != -1 )
{
pluginFilename = pluginFilename.substring( index + 1 );
}
// Absolute path to the plugin file
Path absolutePath = pluginDirectory.resolve( pluginFilename );
Path partFile = pluginDirectory.resolve( pluginFilename + ".part" );
// Save input stream contents to a temp file
Files.copy( in, partFile, StandardCopyOption.REPLACE_EXISTING );
// Rename temp file to .jar
Files.move( partFile, absolutePath, StandardCopyOption.REPLACE_EXISTING );
// Ask the plugin monitor to update the plugin immediately.
pluginMonitor.runNow( true );
}
catch ( IOException e )
{
Log.error( "An exception occurred while installing new version of plugin '{}':", pluginFilename, e );
return false;
}
return true;
}
/**
* Returns true if the specified filename, that belongs to a plugin, exists.
*
* @param pluginFilename the filename of the plugin to create or update.
* @return true if the specified filename, that belongs to a plugin, exists.
*/
public boolean isPluginDownloaded( String pluginFilename )
{
return Files.exists( pluginDirectory.resolve( pluginFilename ) );
}
/**
* Returns a Collection of all installed plugins.
*
* @return a Collection of all installed plugins.
*/
public Collection<Plugin> getPlugins()
{
return Collections.unmodifiableCollection( Arrays.asList( plugins.values().toArray( new Plugin[ plugins.size() ] ) ) );
}
/**
* Returns a plugin by name or <tt>null</tt> if a plugin with that name does not
* exist. The name is the name of the directory that the plugin is in such as
* "broadcast".
*
* @param name the name of the plugin.
* @return the plugin.
*/
public Plugin getPlugin( String name )
{
return plugins.get( name );
}
/**
* @deprecated Use #getPluginPath() instead.
*/
@Deprecated
public File getPluginDirectory( Plugin plugin )
{
return getPluginPath( plugin ).toFile();
}
/**
* Returns the plugin's directory.
*
* @param plugin the plugin.
* @return the plugin's directory.
*/
public Path getPluginPath( Plugin plugin )
{
return pluginDirs.get( plugin );
}
/**
* Returns true if at least one attempt to load plugins has been done. A true value does not mean
* that available plugins have been loaded nor that plugins to be added in the future are already
* loaded. :)<p>
*
* @return true if at least one attempt to load plugins has been done.
*/
public boolean isExecuted()
{
return executed;
}
/**
* Loads a plugin.
*
* @param pluginDir the plugin directory.
*/
void loadPlugin( Path pluginDir )
{
// Only load the admin plugin during setup mode.
final String pluginName = pluginDir.getFileName().toString();
if ( XMPPServer.getInstance().isSetupMode() && !( pluginName.equals( "admin" ) ) )
{
return;
}
Log.debug( "Loading plugin '{}'...", pluginName );
try
{
final Path pluginConfig = pluginDir.resolve( "plugin.xml" );
if ( !Files.exists( pluginConfig ) )
{
Log.warn( "Plugin '{}' could not be loaded: no plugin.xml file found.", pluginName );
return;
}
final SAXReader saxReader = new SAXReader();
saxReader.setEncoding( "UTF-8" );
final Document pluginXML = saxReader.read( pluginConfig.toFile() );
// See if the plugin specifies a version of Openfire required to run.
final Element minServerVersion = (Element) pluginXML.selectSingleNode( "/plugin/minServerVersion" );
if ( minServerVersion != null )
{
final Version requiredVersion = new Version( minServerVersion.getTextTrim() );
final Version currentVersion = XMPPServer.getInstance().getServerInfo().getVersion();
if ( requiredVersion.isNewerThan( currentVersion ) )
{
Log.warn( "Ignoring plugin '{}': requires server version {}. Current server version is {}.", pluginName, requiredVersion, currentVersion );
return;
}
}
// Properties to be used to load external resources. When set, plugin is considered to run in DEV mode.
final String devModeClassesDir = System.getProperty( pluginName + ".classes" );
final String devModewebRoot = System.getProperty( pluginName + ".webRoot" );
final boolean devMode = devModewebRoot != null || devModeClassesDir != null;
final PluginDevEnvironment dev = ( devMode ? configurePluginDevEnvironment( pluginDir, devModeClassesDir, devModewebRoot ) : null );
// Initialize the plugin class loader, which is either a new instance, or a the loader from a parent plugin.
final PluginClassLoader pluginLoader;
// Check to see if this is a child plugin of another plugin. If it is, we re-use the parent plugin's class
// loader so that the plugins can interact.
String parentPluginName = null;
Plugin parentPlugin = null;
final Element parentPluginNode = (Element) pluginXML.selectSingleNode( "/plugin/parentPlugin" );
if ( parentPluginNode != null )
{
// The name of the parent plugin as specified in plugin.xml might have incorrect casing. Lookup the correct name.
for ( final Map.Entry<String, Plugin> entry : plugins.entrySet() )
{
if ( entry.getKey().equalsIgnoreCase( parentPluginNode.getTextTrim() ) )
{
parentPluginName = entry.getKey();
parentPlugin = entry.getValue();
break;
}
}
// See if the parent is loaded.
if ( parentPlugin == null )
{
Log.info( "Unable to load plugin '{}': parent plugin '{}' has not been loaded.", pluginName, parentPluginNode.getTextTrim() );
return;
}
pluginLoader = classloaders.get( parentPlugin );
}
else
{
// This is not a child plugin, so create a new class loader.
pluginLoader = new PluginClassLoader();
}
// Add the plugin sources to the classloaded.
pluginLoader.addDirectory( pluginDir.toFile(), devMode );
// When running in DEV mode, add optional other sources too.
if ( dev != null && dev.getClassesDir() != null )
{
pluginLoader.addURLFile( dev.getClassesDir().toURI().toURL() );
}
// Instantiate the plugin!
final String className = pluginXML.selectSingleNode( "/plugin/class" ).getText().trim();
final Plugin plugin = (Plugin) pluginLoader.loadClass( className ).newInstance();
// Bookkeeping!
classloaders.put( plugin, pluginLoader );
plugins.put( pluginName, plugin );
pluginDirs.put( plugin, pluginDir );
if ( dev != null )
{
pluginDevelopment.put( plugin, dev );
}
// If this is a child plugin, register it as such.
if ( parentPlugin != null )
{
List<String> childrenPlugins = parentPluginMap.get( parentPlugin );
if ( childrenPlugins == null )
{
childrenPlugins = new ArrayList<>();
parentPluginMap.put( parentPlugin, childrenPlugins );
}
childrenPlugins.add( pluginName );
// Also register child to parent relationship.
childPluginMap.put( plugin, parentPluginName );
}
// Check the plugin's database schema (if it requires one).
if ( !DbConnectionManager.getSchemaManager().checkPluginSchema( plugin ) )
{
// The schema was not there and auto-upgrade failed.
Log.error( "Error while loading plugin '{}': {}", pluginName, LocaleUtils.getLocalizedString( "upgrade.database.failure" ) );
}
// Load any JSP's defined by the plugin.
final Path webXML = pluginDir.resolve( "web" ).resolve( "WEB-INF" ).resolve( "web.xml" );
if ( Files.exists( webXML ) )
{
PluginServlet.registerServlets( this, plugin, webXML.toFile() );
}
// Load any custom-defined servlets.
final Path customWebXML = pluginDir.resolve( "web" ).resolve( "WEB-INF" ).resolve( "web-custom.xml" );
if ( Files.exists( customWebXML ) )
{
PluginServlet.registerServlets( this, plugin, customWebXML.toFile() );
}
// Configure caches of the plugin
configureCaches( pluginDir, pluginName );
// Initialze the plugin.
final ClassLoader oldLoader = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader( pluginLoader );
plugin.initializePlugin( this, pluginDir.toFile() );
Log.debug( "Initialized plugin '{}'.", pluginName );
Thread.currentThread().setContextClassLoader( oldLoader );
// If there a <adminconsole> section defined, register it.
final Element adminElement = (Element) pluginXML.selectSingleNode( "/plugin/adminconsole" );
if ( adminElement != null )
{
final Element appName = (Element) adminElement.selectSingleNode( "/plugin/adminconsole/global/appname" );
if ( appName != null )
{
// Set the plugin name so that the proper i18n String can be loaded.
appName.addAttribute( "plugin", pluginName );
}
// If global images are specified, override their URL.
Element imageEl = (Element) adminElement.selectSingleNode( "/plugin/adminconsole/global/logo-image" );
if ( imageEl != null )
{
imageEl.setText( "plugins/" + pluginName + "/" + imageEl.getText() );
imageEl.addAttribute( "plugin", pluginName ); // Set the plugin name so that the proper i18n String can be loaded.
}
imageEl = (Element) adminElement.selectSingleNode( "/plugin/adminconsole/global/login-image" );
if ( imageEl != null )
{
imageEl.setText( "plugins/" + pluginName + "/" + imageEl.getText() );
imageEl.addAttribute( "plugin", pluginName ); // Set the plugin name so that the proper i18n String can be loaded.
}
// Modify all the URL's in the XML so that they are passed through the plugin servlet correctly.
final List urls = adminElement.selectNodes( "//@url" );
for ( final Object url : urls )
{
final Attribute attr = (Attribute) url;
attr.setValue( "plugins/" + pluginName + "/" + attr.getValue() );
}
// In order to internationalize the names and descriptions in the model, we add a "plugin" attribute to
// each tab, sidebar, and item so that the the renderer knows where to load the i18n Strings from.
final String[] elementNames = new String[]{ "tab", "sidebar", "item" };
for ( final String elementName : elementNames )
{
final List values = adminElement.selectNodes( "//" + elementName );
for ( final Object value : values )
{
final Element element = (Element) value;
// Make sure there's a name or description. Otherwise, no need to i18n settings.
if ( element.attribute( "name" ) != null || element.attribute( "value" ) != null )
{
element.addAttribute( "plugin", pluginName );
}
}
}
AdminConsole.addModel( pluginName, adminElement );
}
firePluginCreatedEvent( pluginName, plugin );
Log.info( "Successfully loaded plugin '{}'.", pluginName );
}
catch ( Throwable e )
{
Log.error( "An exception occurred while loading plugin '{}':", pluginName, e );
}
}
private PluginDevEnvironment configurePluginDevEnvironment( final Path pluginDir, String classesDir, String webRoot ) throws IOException
{
final String pluginName = pluginDir.getFileName().toString();
final Path compilationClassesDir = pluginDir.resolve( "classes" );
if ( Files.notExists( compilationClassesDir ) )
{
Files.createDirectory( compilationClassesDir );
}
compilationClassesDir.toFile().deleteOnExit();
final PluginDevEnvironment dev = new PluginDevEnvironment();
Log.info( "Plugin '{}' is running in development mode.", pluginName );
if ( webRoot != null )
{
Path webRootDir = Paths.get( webRoot );
if ( Files.notExists( webRootDir ) )
{
// Ok, let's try it relative from this plugin dir?
webRootDir = pluginDir.resolve( webRoot );
}
if ( Files.exists( webRootDir ) )
{
dev.setWebRoot( webRootDir.toFile() );
}
}
if ( classesDir != null )
{
Path classes = Paths.get( classesDir );
if ( Files.notExists( classes ) )
{
// ok, let's try it relative from this plugin dir?
classes = pluginDir.resolve( classesDir );
}
if ( Files.exists( classes ) )
{
dev.setClassesDir( classes.toFile() );
}
}
return dev;
}
private void configureCaches( Path pluginDir, String pluginName )
{
Path cacheConfig = pluginDir.resolve( "cache-config.xml" );
if ( Files.exists( cacheConfig ) )
{
PluginCacheConfigurator configurator = new PluginCacheConfigurator();
try
{
configurator.setInputStream( new BufferedInputStream( Files.newInputStream( cacheConfig ) ) );
configurator.configure( pluginName );
}
catch ( Exception e )
{
Log.error( "An exception occurred while trying to configure caches for plugin '{}':", pluginName, e );
}
}
}
/**
* Delete a plugin, which removes the plugin.jar/war file after which the plugin is unloaded.
*/
public void deletePlugin( final String pluginName )
{
Log.debug( "Deleting plugin '{}'...", pluginName );
try ( final DirectoryStream<Path> ds = Files.newDirectoryStream( getPluginsDirectory(), new DirectoryStream.Filter<Path>()
{
@Override
public boolean accept( final Path path ) throws IOException
{
if ( Files.isDirectory( path ) )
{
return false;
}
final String fileName = path.getFileName().toString().toLowerCase();
return ( fileName.equals( pluginName + ".jar" ) || fileName.equals( pluginName + ".war" ) );
}
} ) )
{
for ( final Path pluginFile : ds )
{
try
{
Files.delete( pluginFile );
pluginMonitor.runNow( true ); // trigger unload by running the monitor (which is more thread-safe than calling unloadPlugin directly).
}
catch ( IOException ex )
{
Log.warn( "Unable to delete plugin '{}', as the plugin jar/war file cannot be deleted. File path: {}", pluginName, pluginFile, ex );
}
}
}
catch ( Throwable e )
{
Log.error( "An unexpected exception occurred while deleting plugin '{}'.", pluginName, e );
}
}
/**
* Unloads a plugin. The {@link Plugin#destroyPlugin()} method will be called and then any resources will be
* released. The name should be the canonical name of the plugin (based on the plugin directory name) and not the
* human readable name as given by the plugin meta-data.
*
* This method only removes the plugin but does not delete the plugin JAR file. Therefore, if the plugin JAR still
* exists after this method is called, the plugin will be started again the next time the plugin monitor process
* runs. This is useful for "restarting" plugins. To completely remove the plugin, use {@link #deletePlugin(String)}
* instead.
*
* This method is called automatically when a plugin's JAR file is deleted.
*
* @param pluginName the name of the plugin to unload.
*/
public void unloadPlugin( String pluginName )
{
Log.debug( "Unloading plugin '{}'...", pluginName );
Plugin plugin = plugins.get( pluginName );
if ( plugin != null )
{
// Remove from dev mode if it exists.
pluginDevelopment.remove( plugin );
// See if any child plugins are defined.
if ( parentPluginMap.containsKey( plugin ) )
{
String[] childPlugins =
parentPluginMap.get( plugin ).toArray( new String[ parentPluginMap.get( plugin ).size() ] );
parentPluginMap.remove( plugin );
for ( String childPlugin : childPlugins )
{
Log.debug( "Unloading child plugin: '{}'.", childPlugin );
childPluginMap.remove( plugins.get( childPlugin ) );
unloadPlugin( childPlugin );
}
}
Path webXML = pluginDirectory.resolve( pluginName ).resolve( "web" ).resolve( "WEB-INF" ).resolve( "web.xml" );
if ( Files.exists( webXML ) )
{
AdminConsole.removeModel( pluginName );
PluginServlet.unregisterServlets( webXML.toFile() );
}
Path customWebXML = pluginDirectory.resolve( pluginName ).resolve( "web" ).resolve( "WEB-INF" ).resolve( "web-custom.xml" );
if ( Files.exists( customWebXML ) )
{
PluginServlet.unregisterServlets( customWebXML.toFile() );
}
// Wrap destroying the plugin in a try/catch block. Otherwise, an exception raised
// in the destroy plugin process will disrupt the whole unloading process. It's still
// possible that classloader destruction won't work in the case that destroying the plugin
// fails. In that case, Openfire may need to be restarted to fully cleanup the plugin
// resources.
try
{
plugin.destroyPlugin();
Log.debug( "Destroyed plugin '{}'.", pluginName );
}
catch ( Exception e )
{
Log.error( "An exception occurred while unloading plugin '{}':", pluginName, e );
}
}
// Remove references to the plugin so it can be unloaded from memory
// If plugin still fails to be removed then we will add references back
// Anyway, for a few seconds admins may not see the plugin in the admin console
// and in a subsequent refresh it will appear if failed to be removed
plugins.remove( pluginName );
Path pluginFile = pluginDirs.remove( plugin );
PluginClassLoader pluginLoader = classloaders.remove( plugin );
// try to close the cached jar files from the plugin class loader
if ( pluginLoader != null )
{
pluginLoader.unloadJarFiles();
}
else
{
Log.warn( "No plugin loader found for '{}'.", pluginName );
}
// Try to remove the folder where the plugin was exploded. If this works then
// the plugin was successfully removed. Otherwise, some objects created by the
// plugin are still in memory.
Path dir = pluginDirectory.resolve( pluginName );
// Give the plugin 2 seconds to unload.
try
{
Thread.sleep( 2000 );
// Ask the system to clean up references.
System.gc();
int count = 0;
while ( !deleteDir( dir ) && count++ < 5 )
{
Log.warn( "Error unloading plugin '{}'. Will attempt again momentarily.", pluginName );
Thread.sleep( 8000 );
// Ask the system to clean up references.
System.gc();
}
}
catch ( InterruptedException e )
{
Log.debug( "Stopped waiting for plugin '{}' to be fully unloaded.", pluginName, e );
}
if ( plugin != null && Files.notExists( dir ) )
{
// Unregister plugin caches
PluginCacheRegistry.getInstance().unregisterCaches( pluginName );
// See if this is a child plugin. If it is, we should unload
// the parent plugin as well.
if ( childPluginMap.containsKey( plugin ) )
{
String parentPluginName = childPluginMap.get( plugin );
Plugin parentPlugin = plugins.get( parentPluginName );
List<String> childrenPlugins = parentPluginMap.get( parentPlugin );
childrenPlugins.remove( pluginName );
childPluginMap.remove( plugin );
// When the parent plugin implements PluginListener, its pluginDestroyed() method
// isn't called if it dies first before its child. Athough the parent will die anyway,
// it's proper if the parent "gets informed first" about the dying child when the
// child is the one being killed first.
if ( parentPlugin instanceof PluginListener )
{
PluginListener listener;
listener = (PluginListener) parentPlugin;
listener.pluginDestroyed( pluginName, plugin );
}
unloadPlugin( parentPluginName );
}
firePluginDestroyedEvent( pluginName, plugin );
Log.info( "Successfully unloaded plugin '{}'.", pluginName );
}
else if ( plugin != null )
{
Log.info( "Restore references since we failed to remove the plugin '{}'.", pluginName );
plugins.put( pluginName, plugin );
pluginDirs.put( plugin, pluginFile );
classloaders.put( plugin, pluginLoader );
}
}
/**
* Loads a class from the classloader of a plugin.
*
* @param plugin the plugin.
* @param className the name of the class to load.
* @return the class.
* @throws ClassNotFoundException if the class was not found.
* @throws IllegalAccessException if not allowed to access the class.
* @throws InstantiationException if the class could not be created.
*/
public Class loadClass( Plugin plugin, String className ) throws ClassNotFoundException,
IllegalAccessException, InstantiationException
{
PluginClassLoader loader = classloaders.get( plugin );
return loader.loadClass( className );
}
/**
* Returns a plugin's dev environment if development mode is enabled for
* the plugin.
*
* @param plugin the plugin.
* @return the plugin dev environment, or <tt>null</tt> if development
* mode is not enabled for the plugin.
*/
public PluginDevEnvironment getDevEnvironment( Plugin plugin )
{
return pluginDevelopment.get( plugin );
}
/**
* @deprecated Moved to {@link PluginMetadataHelper#getName(Plugin)}.
*/
@Deprecated
public String getName( Plugin plugin )
{
return PluginMetadataHelper.getName( plugin );
}
/**
* @deprecated Moved to {@link PluginMetadataHelper#getDescription(Plugin)}.
*/
@Deprecated
public String getDescription( Plugin plugin )
{
return PluginMetadataHelper.getDescription( plugin );
}
/**
* @deprecated Moved to {@link PluginMetadataHelper#getAuthor(Plugin)}.
*/
@Deprecated
public String getAuthor( Plugin plugin )
{
return PluginMetadataHelper.getAuthor( plugin );
}
/**
* @deprecated Moved to {@link PluginMetadataHelper#getVersion(Plugin)}.
*/
@Deprecated
public String getVersion( Plugin plugin )
{
return PluginMetadataHelper.getVersion( plugin );
}
/**
* @deprecated Moved to {@link PluginMetadataHelper#getMinServerVersion(Plugin)}.
*/
@Deprecated
public String getMinServerVersion( Plugin plugin )
{
return PluginMetadataHelper.getMinServerVersion( plugin );
}
/**
* @deprecated Moved to {@link PluginMetadataHelper#getDatabaseKey(Plugin)}.
*/
@Deprecated
public String getDatabaseKey( Plugin plugin )
{
return PluginMetadataHelper.getDatabaseKey( plugin );
}
/**
* @deprecated Moved to {@link PluginMetadataHelper#getDatabaseVersion(Plugin)}.
*/
@Deprecated
public int getDatabaseVersion( Plugin plugin )
{
return PluginMetadataHelper.getDatabaseVersion( plugin );
}
/**
* @deprecated Moved to {@link PluginMetadataHelper#getLicense(Plugin)}.
*/
@Deprecated
public License getLicense( Plugin plugin )
{
return PluginMetadataHelper.getLicense( plugin );
}
/**
* Returns the classloader of a plugin.
*
* @param plugin the plugin.
* @return the classloader of the plugin.
*/
public PluginClassLoader getPluginClassloader( Plugin plugin )
{
return classloaders.get( plugin );
}
/**
* Deletes a directory.
*
* @param dir the directory to delete.
* @return true if the directory was deleted.
*/
static boolean deleteDir( Path dir )
{
try
{
if ( Files.isDirectory( dir ) )
{
Files.walkFileTree( dir, new SimpleFileVisitor<Path>()
{
@Override
public FileVisitResult visitFile( Path file, BasicFileAttributes attrs ) throws IOException
{
try
{
Files.deleteIfExists( file );
}
catch ( IOException e )
{
Log.debug( "Plugin removal: could not delete: {}", file );
throw e;
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory( Path dir, IOException exc ) throws IOException
{
try
{
Files.deleteIfExists( dir );
}
catch ( IOException e )
{
Log.debug( "Plugin removal: could not delete: {}", dir );
throw e;
}
return FileVisitResult.CONTINUE;
}
} );
}
return Files.notExists( dir ) || Files.deleteIfExists( dir );
}
catch ( IOException e )
{
return Files.notExists( dir );
}
}
/**
* Registers a PluginListener, which will now start receiving events regarding plugin creation and destruction.
*
* When the listener was already registered, this method will have no effect.
*
* @param listener the listener to be notified (cannot be null).
*/
public void addPluginListener( PluginListener listener )
{
pluginListeners.add( listener );
}
/**
* Deregisters a PluginListener, which will no longer receive events.
*
* When the listener was never added, this method will have no effect.
*
* @param listener the listener to be removed (cannot be null).
*/
public void removePluginListener( PluginListener listener )
{
pluginListeners.remove( listener );
}
/**
* Registers a PluginManagerListener, which will now start receiving events regarding plugin management.
*
* @param listener the listener to be notified (cannot be null).
*/
public void addPluginManagerListener( PluginManagerListener listener )
{
pluginManagerListeners.add( listener );
if ( isExecuted() )
{
firePluginsMonitored();
}
}
/**
* Deregisters a PluginManagerListener, which will no longer receive events.
*
* When the listener was never added, this method will have no effect.
*
* @param listener the listener to be notified (cannot be null).
*/
public void removePluginManagerListener( PluginManagerListener listener )
{
pluginManagerListeners.remove( listener );
}
/**
* Notifies all registered PluginListener instances that a new plugin was created.
*
* @param name The name of the plugin
* @param plugin the plugin.
*/
void firePluginCreatedEvent( String name, Plugin plugin )
{
for ( final PluginListener listener : pluginListeners )
{
try
{
listener.pluginCreated( name, plugin );
}
catch ( Exception ex )
{
Log.warn( "An exception was thrown when one of the pluginManagerListeners was notified of a 'created' event for plugin '{}'!", name, ex );
}
}
}
/**
* Notifies all registered PluginListener instances that a plugin was destroyed.
*
* @param name The name of the plugin
* @param plugin the plugin.
*/
void firePluginDestroyedEvent( String name, Plugin plugin )
{
for ( final PluginListener listener : pluginListeners )
{
try
{
listener.pluginDestroyed( name, plugin );
}
catch ( Exception ex )
{
Log.warn( "An exception was thrown when one of the pluginManagerListeners was notified of a 'destroyed' event for plugin '{}'!", name, ex );
}
}
}
/**
* Notifies all registered PluginManagerListener instances that the service monitoring for plugin changes completed a
* periodic check.
*/
void firePluginsMonitored()
{
// Set that at least one iteration was done. That means that "all available" plugins
// have been loaded by now.
if ( !XMPPServer.getInstance().isSetupMode() )
{
executed = true;
}
for ( final PluginManagerListener listener : pluginManagerListeners )
{
try
{
listener.pluginsMonitored();
}
catch ( Exception ex )
{
Log.warn( "An exception was thrown when one of the pluginManagerListeners was notified of a 'monitored' event!", ex );
}
}
}
}
\ No newline at end of file
/*
* Copyright 2016 IgniteRealtime.org
*
* Licensed 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.jivesoftware.openfire.container;
import org.dom4j.Document;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;
import org.jivesoftware.admin.AdminConsole;
import org.jivesoftware.openfire.XMPPServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Various helper methods to retrieve plugin metadat from plugin.xml files.
*
* @author Guus der Kinderen, guus.der.kinderen@gmail.com
*/
public class PluginMetadataHelper
{
private static final Logger Log = LoggerFactory.getLogger( PluginMetadataHelper.class );
/**
* Returns the name of the directory of the parent for this plugin. The value is retrieved from the plugin.xml file
* of the plugin (which is casted down to lower-case). If the value could not be found, <tt>null</tt> will be returned.
*
* @param pluginDir the path of the plugin directory.
* @return the parent plugin's directory name
*/
public static String getParentPlugin( Path pluginDir )
{
final String name = getElementValue( pluginDir, "/plugin/parentPlugin" );
if ( name != null && !name.isEmpty() )
{
return name.toLowerCase();
}
return null;
}
/**
* Returns the canonical name for the plugin, derived from the plugin directory name.
*
* Note that this value can be different from the 'human readable' plugin name, as returned by {@link #getName(Path)}.
*
* Note that this method will return data only for plugins that have successfully been installed. To obtain data
* from plugin (directories) that have not (yet) been installed, refer to the overloaded method that takes a Path
* argument.
*
* @param plugin The plugin (cannot be null)
* @return the plugin's canonical name.
*/
public static String getCanonicalName( Plugin plugin )
{
return getCanonicalName( XMPPServer.getInstance().getPluginManager().getPluginPath( plugin ) );
}
/**
* Returns the canonical name for the plugin, derived from the plugin directory name.
*
* Note that this value can be different from the 'human readable' plugin name, as returned by {@link #getName(Path)}.
*
* @param pluginDir the path of the plugin directory.
* @return the plugin's canonical name.
*/
public static String getCanonicalName( Path pluginDir )
{
return pluginDir.getFileName().toString().toLowerCase();
}
/**
* Returns the name of a plugin. The value is retrieved from the plugin.xml file of the plugin. If the value could not
* be found, <tt>null</tt> will be returned. Note that this value is a 'human readable' name, which can be distinct
* from the name of the plugin directory as returned by {@link #getCanonicalName(Path)}.
*
* Note that this method will return data only for plugins that have successfully been installed. To obtain data
* from plugin (directories) that have not (yet) been installed, refer to the overloaded method that takes a Path
* argument.
*
* @param plugin The plugin (cannot be null)
* @return the plugin's human-readable name.
*/
public static String getName( Plugin plugin )
{
return getName( XMPPServer.getInstance().getPluginManager().getPluginPath( plugin ) );
}
/**
* Returns the name of a plugin. The value is retrieved from the plugin.xml file of the plugin. If the value could not
* be found, <tt>null</tt> will be returned. Note that this value is a 'human readable' name, which can be distinct
* from the name of the plugin directory as returned by {@link #getCanonicalName(Path)}.
*
* @param pluginDir the path of the plugin directory.
* @return the plugin's human-readable name.
*/
public static String getName( Path pluginDir )
{
final String name = getElementValue( pluginDir, "/plugin/name" );
final String pluginName = getCanonicalName( pluginDir );
if ( name != null )
{
return AdminConsole.getAdminText( name, pluginName );
}
else
{
return pluginName;
}
}
/**
* Returns the description of a plugin. The value is retrieved from the plugin.xml file of the plugin. If the value
* could not be found, <tt>null</tt> will be returned.
*
* Note that this method will return data only for plugins that have successfully been installed. To obtain data
* from plugin (directories) that have not (yet) been installed, refer to the overloaded method that takes a Path
* argument.
*
* @param plugin The plugin (cannot be null)
* @return the plugin's description.
*/
public static String getDescription( Plugin plugin )
{
return getDescription( XMPPServer.getInstance().getPluginManager().getPluginPath( plugin ) );
}
/**
* Returns the description of a plugin. The value is retrieved from the plugin.xml file of the plugin. If the value
* could not be found, <tt>null</tt> will be returned.
*
* @param pluginDir the path of the plugin directory.
* @return the plugin's description.
*/
public static String getDescription( Path pluginDir )
{
final String name = getCanonicalName( pluginDir );
final String description = getElementValue( pluginDir, "/plugin/description" );
return AdminConsole.getAdminText( description, name );
}
/**
* Returns the author of a plugin. The value is retrieved from the plugin.xml file of the plugin. If the value could
* not be found, <tt>null</tt> will be returned.
*
* Note that this method will return data only for plugins that have successfully been installed. To obtain data
* from plugin (directories) that have not (yet) been installed, refer to the overloaded method that takes a Path
* argument.
*
* @param plugin The plugin (cannot be null)
* @return the plugin's author.
*/
public static String getAuthor( Plugin plugin )
{
return getAuthor( XMPPServer.getInstance().getPluginManager().getPluginPath( plugin ) );
}
/**
* Returns the author of a plugin. The value is retrieved from the plugin.xml file of the plugin. If the value could
* not be found, <tt>null</tt> will be returned.
*
* @param pluginDir the path of the plugin directory.
* @return the plugin's author.
*/
public static String getAuthor( Path pluginDir )
{
return getElementValue( pluginDir, "/plugin/author" );
}
/**
* Returns the version of a plugin. The value is retrieved from the plugin.xml file of the plugin. If the value
* could not be found, <tt>null</tt> will be returned.
*
* Note that this method will return data only for plugins that have successfully been installed. To obtain data
* from plugin (directories) that have not (yet) been installed, refer to the overloaded method that takes a Path
* argument.
*
* @param plugin The plugin (cannot be null)
* @return the plugin's version.
*/
public static String getVersion( Plugin plugin )
{
return getVersion( XMPPServer.getInstance().getPluginManager().getPluginPath( plugin ) );
}
/**
* Returns the version of a plugin. The value is retrieved from the plugin.xml file of the plugin. If the value
* could not be found, <tt>null</tt> will be returned.
*
* @param pluginDir the path of the plugin directory.
* @return the plugin's version.
*/
public static String getVersion( Path pluginDir )
{
return getElementValue( pluginDir, "/plugin/version" );
}
/**
* Returns the minimum server version this plugin can run within. The value is retrieved from the plugin.xml file
* of the plugin. If the value could not be found, <tt>null</tt> will be returned.
*
* Note that this method will return data only for plugins that have successfully been installed. To obtain data
* from plugin (directories) that have not (yet) been installed, refer to the overloaded method that takes a Path
* argument.
*
* @param plugin The plugin (cannot be null)
* @return the plugin's minimum server version.
*/
public static String getMinServerVersion( Plugin plugin )
{
return getMinServerVersion( XMPPServer.getInstance().getPluginManager().getPluginPath( plugin ) );
}
/**
* Returns the minimum server version this plugin can run within. The value is retrieved from the plugin.xml file
* of the plugin. If the value could not be found, <tt>null</tt> will be returned.
*
* @param pluginDir the path of the plugin directory.
* @return the plugin's minimum server version.
*/
public static String getMinServerVersion( Path pluginDir )
{
return getElementValue( pluginDir, "/plugin/minServerVersion" );
}
/**
* Returns the database schema key of a plugin, if it exists. The value is retrieved from the plugin.xml file of the
* plugin. If the value could not be found, <tt>null</tt> will be returned.
*
* Note that this method will return data only for plugins that have successfully been installed. To obtain data
* from plugin (directories) that have not (yet) been installed, refer to the overloaded method that takes a Path
* argument.
*
* @param plugin The plugin (cannot be null)
* @return the plugin's database schema key or <tt>null</tt> if it doesn't exist.
*/
public static String getDatabaseKey( Plugin plugin )
{
return getDatabaseKey( XMPPServer.getInstance().getPluginManager().getPluginPath( plugin ) );
}
/**
* Returns the database schema key of a plugin, if it exists. The value is retrieved from the plugin.xml file of the
* plugin. If the value could not be found, <tt>null</tt> will be returned.
*
* @param pluginDir the path of the plugin directory.
* @return the plugin's database schema key or <tt>null</tt> if it doesn't exist.
*/
public static String getDatabaseKey( Path pluginDir )
{
return getElementValue( pluginDir, "/plugin/databaseKey" );
}
/**
* Returns the database schema version of a plugin, if it exists. The value is retrieved from the plugin.xml file of
* the plugin. If the value could not be found, <tt>-1</tt> will be returned.
*
* Note that this method will return data only for plugins that have successfully been installed. To obtain data
* from plugin (directories) that have not (yet) been installed, refer to the overloaded method that takes a Path
* argument.
*
* @param plugin The plugin (cannot be null)
* @return the plugin's database schema version or <tt>-1</tt> if it doesn't exist.
*/
public static int getDatabaseVersion( Plugin plugin )
{
return getDatabaseVersion( XMPPServer.getInstance().getPluginManager().getPluginPath( plugin ) );
}
/**
* Returns the database schema version of a plugin, if it exists. The value is retrieved from the plugin.xml file of
* the plugin. If the value could not be found, <tt>-1</tt> will be returned.
*
* @param pluginDir the path of the plugin directory.
* @return the plugin's database schema version or <tt>-1</tt> if it doesn't exist.
*/
public static int getDatabaseVersion( Path pluginDir )
{
String versionString = getElementValue( pluginDir, "/plugin/databaseVersion" );
if ( versionString != null )
{
try
{
return Integer.parseInt( versionString.trim() );
}
catch ( NumberFormatException nfe )
{
Log.error( "Unable to parse the database version for plugin '{}'.", getCanonicalName( pluginDir ), nfe );
}
}
return -1;
}
/**
* Returns the license agreement type that the plugin is governed by. The value is retrieved from the plugin.xml
* file of the plugin. If the value could not be found, {@link License#other} is returned.
*
* Note that this method will return data only for plugins that have successfully been installed. To obtain data
* from plugin (directories) that have not (yet) been installed, refer to the overloaded method that takes a Path
* argument.
*
* @param plugin The plugin (cannot be null)
* @return the plugin's license agreement.
*/
public static License getLicense( Plugin plugin )
{
return getLicense( XMPPServer.getInstance().getPluginManager().getPluginPath( plugin ) );
}
/**
* Returns the license agreement type that the plugin is governed by. The value is retrieved from the plugin.xml
* file of the plugin. If the value could not be found, {@link License#other} is returned.
*
* @param pluginDir the path of the plugin directory.
* @return the plugin's license agreement.
*/
public static License getLicense( Path pluginDir )
{
String licenseString = getElementValue( pluginDir, "/plugin/licenseType" );
if ( licenseString != null )
{
try
{
// Attempt to load the get the license type. We lower-case and trim the license type to give plugin
// author's a break. If the license type is not recognized, we'll log the error and default to "other".
return License.valueOf( licenseString.toLowerCase().trim() );
}
catch ( IllegalArgumentException iae )
{
Log.error( "Unrecognized license type '{}' for plugin '{}'.", licenseString.toLowerCase().trim(), getCanonicalName( pluginDir ), iae );
}
}
return License.other;
}
/**
* Returns the value of an element selected via an xpath expression from
* a Plugin's plugin.xml file.
*
* @param pluginDir the path of the plugin directory.
* @param xpath the xpath expression.
* @return the value of the element selected by the xpath expression.
*/
static String getElementValue( Path pluginDir, String xpath )
{
if ( pluginDir == null )
{
return null;
}
try
{
final Path pluginConfig = pluginDir.resolve( "plugin.xml" );
if ( Files.exists( pluginConfig ) )
{
final SAXReader saxReader = new SAXReader();
saxReader.setEncoding( "UTF-8" );
final Document pluginXML = saxReader.read( pluginConfig.toFile() );
final Element element = (Element) pluginXML.selectSingleNode( xpath );
if ( element != null )
{
return element.getTextTrim();
}
}
}
catch ( Exception e )
{
Log.error( "Unable to get element value '{}' from plugin.xml of plugin in '{}':", xpath, pluginDir, e );
}
return null;
}
}
/*
* Copyright 2016 IgniteRealtime.org
*
* Licensed 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.jivesoftware.openfire.container;
import org.jivesoftware.openfire.XMPPServer;
import org.jivesoftware.util.JiveGlobals;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.zip.ZipFile;
/**
* A service that monitors the plugin directory for plugins. It periodically checks for new plugin JAR files and
* extracts them if they haven't already been extracted. Then, any new plugin directories are loaded, using the
* PluginManager.
*
* @author Guus der Kinderen, guus.der.kinderen@gmail.com
*/
public class PluginMonitor
{
private static final Logger Log = LoggerFactory.getLogger( PluginMonitor.class );
private final PluginManager pluginManager;
private ScheduledExecutorService executor;
public PluginMonitor( final PluginManager pluginManager )
{
this.pluginManager = pluginManager;
}
/**
* Start periodically checking the plugin directory.
*/
public void start()
{
if ( executor != null )
{
executor.shutdown();
}
executor = new ScheduledThreadPoolExecutor( 1 );
// See if we're in development mode. If so, check for new plugins once every 5 seconds Otherwise, default to every 20 seconds.
if ( Boolean.getBoolean( "developmentMode" ) )
{
executor.scheduleWithFixedDelay( new MonitorTask(), 0, 5, TimeUnit.SECONDS );
}
else
{
executor.scheduleWithFixedDelay( new MonitorTask(), 0, 20, TimeUnit.SECONDS );
}
}
/**
* Stop periodically checking the plugin directory.
*/
public void stop()
{
if ( executor != null )
{
executor.shutdown();
}
}
/**
* Immediately run a check of the plugin directory.
*/
public void runNow( boolean blockUntilDone )
{
final Future<?> future = executor.submit( new MonitorTask() );
if ( blockUntilDone )
{
try
{
future.get();
}
catch ( Exception e )
{
Log.warn( "An exception occurred while waiting for a check of the plugin directory to complete.", e );
}
}
}
private class MonitorTask implements Runnable
{
@Override
public void run()
{
// Prevent two tasks from running in parallel by using the plugin monitor itself as a mutex.
synchronized ( PluginMonitor.this )
{
try
{
// The directory that contains all plugins.
final Path pluginsDirectory = pluginManager.getPluginsDirectory();
if ( !Files.isDirectory( pluginsDirectory ) || !Files.isReadable( pluginsDirectory ) )
{
Log.error( "Unable to process plugins. The plugins directory does not exist (or is no directory): {}", pluginsDirectory );
return;
}
// Turn the list of JAR/WAR files into a set so that we can do lookups.
final Set<String> jarSet = new HashSet<>();
// Explode all plugin files that have not yet been exploded (or need to be re-exploded).
try ( final DirectoryStream<Path> ds = Files.newDirectoryStream( pluginsDirectory, new DirectoryStream.Filter<Path>()
{
@Override
public boolean accept( final Path path ) throws IOException
{
if ( Files.isDirectory( path ) )
{
return false;
}
final String fileName = path.getFileName().toString().toLowerCase();
return ( fileName.endsWith( ".jar" ) || fileName.endsWith( ".war" ) );
}
} ) )
{
for ( final Path jarFile : ds )
{
final String fileName = jarFile.getFileName().toString();
final String pluginName = fileName.substring( 0, fileName.length() - 4 ).toLowerCase(); // strip extension.
jarSet.add( pluginName );
// See if the JAR has already been exploded.
final Path dir = pluginsDirectory.resolve( pluginName );
// See if the JAR is newer than the directory. If so, the plugin needs to be unloaded and then reloaded.
if ( Files.exists( dir ) && Files.getLastModifiedTime( jarFile ).toMillis() > Files.getLastModifiedTime( dir ).toMillis() )
{
// If this is the first time that the monitor process is running, then plugins won't be loaded yet. Therefore, just delete the directory.
if ( pluginManager.isExecuted() )
{
int count = 0;
// Attempt to delete the folder for up to 5 seconds.
while ( !PluginManager.deleteDir( dir ) && count++ < 5 )
{
Thread.sleep( 1000 );
}
}
else
{
// Not the first time? Properly unload the plugin.
pluginManager.unloadPlugin( pluginName );
}
}
// If the JAR needs to be exploded, do so.
if ( Files.notExists( dir ) )
{
unzipPlugin( pluginName, jarFile, dir );
}
}
}
// See if any currently running plugins need to be unloaded due to the JAR file being deleted. Note
// that unloading a parent plugin might cause more than one plugin to disappear. Don't reuse the
// directory stream afterwards!
try ( final DirectoryStream<Path> ds = Files.newDirectoryStream( pluginsDirectory, new DirectoryStream.Filter<Path>()
{
@Override
public boolean accept( final Path path ) throws IOException
{
if ( !Files.isDirectory( path ) )
{
return false;
}
final String pluginName = PluginMetadataHelper.getCanonicalName( path );
return !pluginName.equals( "admin" ) && !jarSet.contains( pluginName );
}
} ) )
{
for ( final Path path : ds )
{
final String pluginName = PluginMetadataHelper.getCanonicalName( path );
Log.info( "Plugin '{}' was removed from the file system.", pluginName );
pluginManager.unloadPlugin( pluginName );
}
}
// Load all plugins that need to be loaded. Make sure that the admin plugin is loaded first (as that
// should be available as soon as possible), followed by all other plugins. Ensure that parent plugins
// are loaded before their children.
try ( final DirectoryStream<Path> ds = Files.newDirectoryStream( pluginsDirectory, new DirectoryStream.Filter<Path>()
{
@Override
public boolean accept( final Path path ) throws IOException
{
return Files.isDirectory( path );
}
} ) )
{
// Look for extra plugin directories specified as a system property.
final Set<Path> devPlugins = new HashSet<>();
final String devPluginDirs = System.getProperty( "pluginDirs" );
if ( devPluginDirs != null )
{
final StringTokenizer st = new StringTokenizer( devPluginDirs, "," );
while ( st.hasMoreTokens() )
{
try
{
final String devPluginDir = st.nextToken().trim();
final Path devPluginPath = Paths.get( devPluginDir );
if ( Files.exists( devPluginPath ) && Files.isDirectory( devPluginPath ) )
{
devPlugins.add( devPluginPath );
}
else
{
Log.error( "Unable to load a dev plugin as its path (as supplied in the 'pluginDirs' system property) does not exist, or is not a directory. Offending path: [{}] (parsed from raw value [{}])", devPluginPath, devPluginDir );
}
}
catch ( InvalidPathException ex )
{
Log.error( "Unable to load a dev plugin as an invalid path was added to the 'pluginDirs' system property.", ex );
}
}
}
// Sort the list of directories so that the "admin" plugin is always first in the list, and 'parent'
// plugins always precede their children.
final Deque<List<Path>> dirs = sortPluginDirs( ds, devPlugins );
// Hierarchy processing could be parallel.
final Collection<Callable<Integer>> parallelProcesses = new ArrayList<>();
for ( final List<Path> hierarchy : dirs )
{
parallelProcesses.add( new Callable<Integer>()
{
@Override
public Integer call() throws Exception
{
int loaded = 0;
for ( final Path path : hierarchy )
{
// If the plugin hasn't already been started, start it.
final String pluginName = PluginMetadataHelper.getCanonicalName( path );
if ( pluginManager.getPlugin( pluginName ) == null )
{
pluginManager.loadPlugin( path );
loaded++;
}
}
return loaded;
}
} );
}
// Hierarchies could be processed in parallel. This is likely to be beneficial during the first
// execution of this monitor, as during later executions, most plugins will likely already be loaded.
final int parallelProcessMax = JiveGlobals.getIntProperty( "plugins.loading.max-parallel", 4 );
final int parallelProcessCount = ( pluginManager.isExecuted() ? 1 : parallelProcessMax );
final ExecutorService executorService = Executors.newFixedThreadPool( parallelProcessCount );
try
{
// Blocks until ready
final List<Future<Integer>> futures = executorService.invokeAll( parallelProcesses );
// Unless nothing happened, report that we're done loading plugins.
int pluginsLoaded = 0;
for ( Future<Integer> future : futures )
{
pluginsLoaded += future.get();
}
if ( pluginsLoaded > 0 && !XMPPServer.getInstance().isSetupMode() )
{
Log.info( "Finished processing all plugins." );
}
}
finally
{
executorService.shutdown();
}
// Trigger event that plugins have been monitored
pluginManager.firePluginsMonitored();
}
}
catch ( Throwable e )
{
Log.error( "An unexpected exception occurred:", e );
}
}
}
/**
* Unzips a plugin from a JAR file into a directory. If the JAR file
* isn't a plugin, this method will do nothing.
*
* @param pluginName the name of the plugin.
* @param file the JAR file
* @param dir the directory to extract the plugin to.
*/
private void unzipPlugin( String pluginName, Path file, Path dir )
{
try ( ZipFile zipFile = new JarFile( file.toFile() ) )
{
// Ensure that this JAR is a plugin.
if ( zipFile.getEntry( "plugin.xml" ) == null )
{
return;
}
Files.createDirectory( dir );
// Set the date of the JAR file to the newly created folder
Files.setLastModifiedTime( dir, Files.getLastModifiedTime( file ) );
Log.debug( "Extracting plugin '{}'...", pluginName );
for ( Enumeration e = zipFile.entries(); e.hasMoreElements(); )
{
JarEntry entry = (JarEntry) e.nextElement();
Path entryFile = dir.resolve( entry.getName() );
// Ignore any manifest.mf entries.
if ( entry.getName().toLowerCase().endsWith( "manifest.mf" ) )
{
continue;
}
if ( !entry.isDirectory() )
{
Files.createDirectories( entryFile.getParent() );
try ( InputStream zin = zipFile.getInputStream( entry ) )
{
Files.copy( zin, entryFile, StandardCopyOption.REPLACE_EXISTING );
}
}
}
Log.debug( "Successfully extracted plugin '{}'.", pluginName );
}
catch ( Exception e )
{
Log.error( "An exception occurred while trying to extract plugin '{}':", pluginName, e );
}
}
/**
* Returns all plugin directories, in a deque of lists with these characteristics:
* <ol>
* <li>Every list is a hierarchy of parent/child plugins (or is a list of one element).</li>
* <li>Every list is ordered to ensure that all parent plugins have a lower index than their children.</li>
* <li>The first element of every list will be a plugin that has no 'parent' plugin.</li>
* <li>the first element of the first list will be the 'admin' plugin.</li>
* </ol>
*
* Plugins within the provided argument that refer to non-existing parent plugins will not be part of the returned
* collection.
*
* @param dirs Collections of paths that refer every plugin directory (but not the corresponding .jar/.war files).
* @return An ordered collection of paths.
*/
@SafeVarargs
private final Deque<List<Path>> sortPluginDirs( Iterable<Path>... dirs )
{
// Map all plugins to they parent plugin (lower-cased), using a null key for parent-less plugins;
final Map<String, Set<Path>> byParent = new HashMap<>();
for ( final Iterable<Path> iterable : dirs )
{
for ( final Path dir : iterable )
{
final String parent = PluginMetadataHelper.getParentPlugin( dir );
if ( !byParent.containsKey( parent ) )
{
byParent.put( parent, new HashSet<Path>() );
}
byParent.get( parent ).add( dir );
}
}
// Transform the map into a tree structure (where the root node is a placeholder without data).
final Node root = new Node();
populateTree( root, byParent );
// byParent should be consumed. Remaining entries are depending on a non-existing parent.
for ( Map.Entry<String, Set<Path>> entry : byParent.entrySet() )
{
if ( !entry.getValue().isEmpty() )
{
for ( final Path path : entry.getValue() )
{
final String name = PluginMetadataHelper.getCanonicalName( path );
Log.warn( "Unable to load plugin '{}' as its defined parent plugin '{}' is not installed.", name, entry.getKey() );
}
}
}
// Return a deque of lists, where each list is parent-child chain of plugins (the parents preceding its children).
final Deque<List<Path>> result = new ArrayDeque<>();
for ( final Node noParentPlugin : root.children )
{
final List<Path> hierarchy = new ArrayList<>();
walkTree( noParentPlugin, hierarchy );
// The admin plugin should go first
if ( noParentPlugin.getName().equals( "admin" ) )
{
result.addFirst( hierarchy );
}
else
{
result.addLast( hierarchy );
}
}
return result;
}
private void populateTree( final Node parent, Map<String, Set<Path>> byParent )
{
final String parentName = parent.path == null ? null : PluginMetadataHelper.getCanonicalName( parent.path );
final Set<Path> children = byParent.remove( parentName );
if ( children != null )
{
for ( final Path child : children )
{
final Node node = new Node();
node.path = child;
if ( !parent.children.add( node ) )
{
Log.warn( "Detected plugin duplicates for name: '{}'. Only one plugin will be loaded.", node.getName() );
}
// recurse to find further children.
populateTree( node, byParent );
}
}
}
private void walkTree( final Node node, List<Path> result )
{
result.add( node.path );
if ( node.children != null )
{
for ( Node child : node.children )
{
walkTree( child, result );
}
}
}
class Node
{
Path path;
SortedSet<Node> children = new TreeSet<>( new Comparator<Node>()
{
@Override
public int compare( Node o1, Node o2 )
{
return o1.getName().compareToIgnoreCase( o2.getName() );
}
} );
String getName()
{
return PluginMetadataHelper.getCanonicalName( path );
}
}
}
}
......@@ -85,14 +85,7 @@
}
if (csrf_check && deletePlugin != null) {
File pluginDir = pluginManager.getPluginDirectory(pluginManager.getPlugin(deletePlugin));
File pluginJar = new File(pluginDir.getParent(), pluginDir.getName() + ".jar");
// Also try the .war extension.
if (!pluginJar.exists()) {
pluginJar = new File(pluginDir.getParent(), pluginDir.getName() + ".war");
}
pluginJar.delete();
pluginManager.unloadPlugin(pluginDir.getName());
pluginManager.deletePlugin( deletePlugin );
// Log the event
webManager.logEvent("deleted plugin "+deletePlugin, null);
response.sendRedirect("plugin-admin.jsp?deletesuccess=true");
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment