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 @@ ...@@ -79,6 +79,9 @@
<logger name="org.jivesoftware.openfire.container.PluginManager"> <logger name="org.jivesoftware.openfire.container.PluginManager">
<appender-ref ref="console"/> <appender-ref ref="console"/>
</logger> </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. --> <!-- OF-506: Jetty INFO messages are generally not useful. Ignore them by default. -->
<logger name="org.eclipse.jetty"> <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$ * $RCSfile$
* $Revision: 3001 $ * $Revision: 3001 $
* $Date: 2005-10-31 05:39:25 -0300 (Mon, 31 Oct 2005) $ * $Date: 2005-10-31 05:39:25 -0300 (Mon, 31 Oct 2005) $
* *
* Copyright (C) 2004-2008 Jive Software. All rights reserved. * Copyright (C) 2004-2008 Jive Software. All rights reserved.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
* You may obtain a copy of the License at * You may obtain a copy of the License at
* *
* http://www.apache.org/licenses/LICENSE-2.0 * http://www.apache.org/licenses/LICENSE-2.0
* *
* Unless required by applicable law or agreed to in writing, software * Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, * distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
package org.jivesoftware.openfire.container; package org.jivesoftware.openfire.container;
import org.dom4j.Attribute; import org.dom4j.Attribute;
import org.dom4j.Document; import org.dom4j.Document;
import org.dom4j.Element; import org.dom4j.Element;
import org.dom4j.io.SAXReader; import org.dom4j.io.SAXReader;
import org.jivesoftware.admin.AdminConsole; import org.jivesoftware.admin.AdminConsole;
import org.jivesoftware.database.DbConnectionManager; import org.jivesoftware.database.DbConnectionManager;
import org.jivesoftware.openfire.XMPPServer; import org.jivesoftware.openfire.XMPPServer;
import org.jivesoftware.util.LocaleUtils; import org.jivesoftware.util.LocaleUtils;
import org.jivesoftware.util.Version; import org.jivesoftware.util.Version;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.BufferedInputStream; import java.io.BufferedInputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.DirectoryStream; import java.nio.file.*;
import java.nio.file.FileVisitResult; import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.Files; import java.util.*;
import java.nio.file.Path; import java.util.concurrent.CopyOnWriteArraySet;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor; /**
import java.nio.file.StandardCopyOption; * Loads and manages plugins. The <tt>plugins</tt> directory is monitored for any
import java.nio.file.attribute.BasicFileAttributes; * new plugins, and they are dynamically loaded.
import java.util.*; *
import java.util.concurrent.CopyOnWriteArraySet; * <p>An instance of this class can be obtained using:</p>
import java.util.concurrent.ScheduledExecutorService; *
import java.util.concurrent.ScheduledThreadPoolExecutor; * <tt>XMPPServer.getInstance().getPluginManager()</tt>
import java.util.concurrent.TimeUnit; *
import java.util.jar.JarEntry; * @author Matt Tucker
import java.util.jar.JarFile; * @see Plugin
import java.util.zip.ZipFile; * @see org.jivesoftware.openfire.XMPPServer#getPluginManager()
*/
/** public class PluginManager
* Loads and manages plugins. The <tt>plugins</tt> directory is monitored for any {
* new plugins, and they are dynamically loaded.
* private static final Logger Log = LoggerFactory.getLogger( PluginManager.class );
* <p>An instance of this class can be obtained using:</p>
* private final Path pluginDirectory;
* <tt>XMPPServer.getInstance().getPluginManager()</tt> private final Map<String, Plugin> plugins = new TreeMap<>( String.CASE_INSENSITIVE_ORDER );
* private final Map<Plugin, PluginClassLoader> classloaders = new HashMap<>();
* @author Matt Tucker private final Map<Plugin, Path> pluginDirs = new HashMap<>();
* @see Plugin private final Map<Plugin, PluginDevEnvironment> pluginDevelopment = new HashMap<>();
* @see org.jivesoftware.openfire.XMPPServer#getPluginManager() private final Map<Plugin, List<String>> parentPluginMap = new HashMap<>();
*/ private final Map<Plugin, String> childPluginMap = new HashMap<>();
public class PluginManager { private final Set<PluginListener> pluginListeners = new CopyOnWriteArraySet<>();
private final Set<PluginManagerListener> pluginManagerListeners = new CopyOnWriteArraySet<>();
private static final Logger Log = LoggerFactory.getLogger(PluginManager.class);
private final PluginMonitor pluginMonitor;
private Path pluginDirectory; private boolean executed = false;
private Map<String, Plugin> plugins;
private Map<Plugin, PluginClassLoader> classloaders; /**
private Map<Plugin, Path> pluginDirs; * Constructs a new plugin manager.
/** *
* Keep track of plugin names and their unzipped files. This list is updated when plugin * @param pluginDir the directory containing all Openfire plugins, typically OPENFIRE_HOME/plugins/
* is exploded and not when is loaded. */
*/ public PluginManager( File pluginDir )
private Map<String, Path> pluginFiles; {
private ScheduledExecutorService executor = null; this.pluginDirectory = pluginDir.toPath();
private Map<Plugin, PluginDevEnvironment> pluginDevelopment; pluginMonitor = new PluginMonitor( this );
private Map<Plugin, List<String>> parentPluginMap; }
private Map<Plugin, String> childPluginMap;
private Set<String> devPlugins; /**
private PluginMonitor pluginMonitor; * Starts plugins and the plugin monitoring service.
private Set<PluginListener> pluginListeners = new CopyOnWriteArraySet<>(); */
private Set<PluginManagerListener> pluginManagerListeners = new CopyOnWriteArraySet<>(); public synchronized void start()
{
/** pluginMonitor.start();
* Constructs a new plugin manager. }
*
* @param pluginDir the plugin directory. /**
*/ * Shuts down all running plugins.
public PluginManager(File pluginDir) { */
this.pluginDirectory = pluginDir.toPath(); public synchronized void shutdown()
plugins = new TreeMap<>( String.CASE_INSENSITIVE_ORDER ); {
pluginDirs = new HashMap<>(); Log.info( "Shutting down. Unloading all installed plugins..." );
pluginFiles = new TreeMap<>( String.CASE_INSENSITIVE_ORDER );
classloaders = new HashMap<>(); // Stop the plugin monitoring service.
pluginDevelopment = new HashMap<>(); pluginMonitor.stop();
parentPluginMap = new HashMap<>();
childPluginMap = new HashMap<>(); // Shutdown all installed plugins.
devPlugins = new TreeSet<>( String.CASE_INSENSITIVE_ORDER ); for ( Map.Entry<String, Plugin> plugin : plugins.entrySet() )
pluginMonitor = new PluginMonitor(); {
} try
{
/** plugin.getValue().destroyPlugin();
* Starts plugins and the plugin monitoring service. Log.info( "Unloaded plugin '{}'.", plugin.getKey() );
*/ }
public void start() { catch ( Exception e )
executor = new ScheduledThreadPoolExecutor(1); {
// See if we're in development mode. If so, check for new plugins once every 5 seconds. Log.error( "An exception occurred while trying to unload plugin '{}':", plugin.getKey(), e );
// Otherwise, default to every 20 seconds. }
if (Boolean.getBoolean("developmentMode")) { }
executor.scheduleWithFixedDelay(pluginMonitor, 0, 5, TimeUnit.SECONDS); plugins.clear();
} pluginDirs.clear();
else { classloaders.clear();
executor.scheduleWithFixedDelay(pluginMonitor, 0, 20, TimeUnit.SECONDS); pluginDevelopment.clear();
} childPluginMap.clear();
} }
/** /**
* Shuts down all running plugins. * Returns the directory that contains all plugins. This typically is OPENFIRE_HOME/plugins.
*/ *
public void shutdown() { * @return The directory that contains all plugins.
Log.info("Shutting down. Unloading all installed plugins..."); */
// Stop the plugin monitoring service. public Path getPluginsDirectory()
if (executor != null) { {
executor.shutdown(); return pluginDirectory;
} }
// Shutdown all installed plugins.
for (Map.Entry<String, Plugin> plugin : plugins.entrySet()) { /**
try { * Installs or updates an existing plugin.
plugin.getValue().destroyPlugin(); *
Log.info("Unloaded plugin '{}'.", plugin.getKey()); * @param in the input stream that contains the new plugin definition.
} * @param pluginFilename the filename of the plugin to create or update.
catch (Exception e) { * @return true if the plugin was successfully installed or updated.
Log.error("An exception occurred while trying to unload plugin '{}':", plugin.getKey(), e); */
} public boolean installPlugin( InputStream in, String pluginFilename )
} {
plugins.clear(); if ( pluginFilename == null || pluginFilename.isEmpty() )
pluginDirs.clear(); {
pluginFiles.clear(); Log.error( "Error installing plugin: pluginFilename was null or empty." );
classloaders.clear(); return false;
pluginDevelopment.clear(); }
childPluginMap.clear(); if ( in == null )
pluginMonitor = null; {
} Log.error( "Error installing plugin '{}': Input stream was null.", pluginFilename );
return false;
/** }
* Installs or updates an existing plugin. try
* {
* @param in the input stream that contains the new plugin definition. // If pluginFilename is a path instead of a simple file name, we only want the file name
* @param pluginFilename the filename of the plugin to create or update. int index = pluginFilename.lastIndexOf( File.separator );
* @return true if the plugin was successfully installed or updated. if ( index != -1 )
*/ {
public boolean installPlugin(InputStream in, String pluginFilename) { pluginFilename = pluginFilename.substring( index + 1 );
if ( pluginFilename == null || pluginFilename.isEmpty()) { }
Log.error("Error installing plugin: pluginFilename was null or empty."); // Absolute path to the plugin file
return false; Path absolutePath = pluginDirectory.resolve( pluginFilename );
} Path partFile = pluginDirectory.resolve( pluginFilename + ".part" );
if (in == null) { // Save input stream contents to a temp file
Log.error("Error installing plugin '{}': Input stream was null.", pluginFilename); Files.copy( in, partFile, StandardCopyOption.REPLACE_EXISTING );
return false;
} // Rename temp file to .jar
try { Files.move( partFile, absolutePath, StandardCopyOption.REPLACE_EXISTING );
// If pluginFilename is a path instead of a simple file name, we only want the file name // Ask the plugin monitor to update the plugin immediately.
int index = pluginFilename.lastIndexOf(File.separator); pluginMonitor.runNow( true );
if (index != -1) { }
pluginFilename = pluginFilename.substring(index+1); catch ( IOException e )
} {
// Absolute path to the plugin file Log.error( "An exception occurred while installing new version of plugin '{}':", pluginFilename, e );
Path absolutePath = pluginDirectory.resolve(pluginFilename); return false;
Path partFile = pluginDirectory.resolve(pluginFilename + ".part"); }
// Save input stream contents to a temp file return true;
Files.copy(in, partFile, StandardCopyOption.REPLACE_EXISTING); }
// Rename temp file to .jar /**
Files.move(partFile, absolutePath, StandardCopyOption.REPLACE_EXISTING); * Returns true if the specified filename, that belongs to a plugin, exists.
// Ask the plugin monitor to update the plugin immediately. *
pluginMonitor.run(); * @param pluginFilename the filename of the plugin to create or update.
} * @return true if the specified filename, that belongs to a plugin, exists.
catch (IOException e) { */
Log.error("An exception occurred while installing new version of plugin '{}':", pluginFilename, e); public boolean isPluginDownloaded( String pluginFilename )
return false; {
} return Files.exists( pluginDirectory.resolve( pluginFilename ) );
return true; }
}
/**
/** * Returns a Collection of all installed plugins.
* Returns true if the specified filename, that belongs to a plugin, exists. *
* * @return a Collection of all installed plugins.
* @param pluginFilename the filename of the plugin to create or update. */
* @return true if the specified filename, that belongs to a plugin, exists. public Collection<Plugin> getPlugins()
*/ {
public boolean isPluginDownloaded(String pluginFilename) { return Collections.unmodifiableCollection( Arrays.asList( plugins.values().toArray( new Plugin[ plugins.size() ] ) ) );
return Files.exists(pluginDirectory.resolve(pluginFilename)); }
}
/**
/** * Returns a plugin by name or <tt>null</tt> if a plugin with that name does not
* Returns a Collection of all installed plugins. * exist. The name is the name of the directory that the plugin is in such as
* * "broadcast".
* @return a Collection of all installed plugins. *
*/ * @param name the name of the plugin.
public Collection<Plugin> getPlugins() { * @return the plugin.
return Collections.unmodifiableCollection(Arrays.asList( plugins.values().toArray( new Plugin[ plugins.size() ]) )); */
} public Plugin getPlugin( String name )
{
/** return plugins.get( name );
* 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". /**
* * @deprecated Use #getPluginPath() instead.
* @param name the name of the plugin. */
* @return the plugin. @Deprecated
*/ public File getPluginDirectory( Plugin plugin )
public Plugin getPlugin(String name) { {
return plugins.get(name); return getPluginPath( plugin ).toFile();
} }
/** /**
* Returns the plugin's directory. * Returns the plugin's directory.
* *
* @param plugin the plugin. * @param plugin the plugin.
* @return the plugin's directory. * @return the plugin's directory.
*/ */
public File getPluginDirectory(Plugin plugin) { public Path getPluginPath( Plugin plugin )
return pluginDirs.get(plugin).toFile(); {
} return pluginDirs.get( plugin );
}
/**
* Returns the JAR or WAR file that created the plugin. /**
* * Returns true if at least one attempt to load plugins has been done. A true value does not mean
* @param name the name of the plugin. * that available plugins have been loaded nor that plugins to be added in the future are already
* @return the plugin JAR or WAR file. * loaded. :)<p>
*/ *
public File getPluginFile(String name) { * @return true if at least one attempt to load plugins has been done.
return pluginFiles.get(name).toFile(); */
} public boolean isExecuted()
{
/** return executed;
* 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> /**
* * Loads a plugin.
* 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. * @param pluginDir the plugin directory.
* */
* @return true if at least one attempt to load plugins has been done. void loadPlugin( Path pluginDir )
*/ {
public boolean isExecuted() { // Only load the admin plugin during setup mode.
return pluginMonitor.executed; final String pluginName = pluginDir.getFileName().toString();
} if ( XMPPServer.getInstance().isSetupMode() && !( pluginName.equals( "admin" ) ) )
{
/** return;
* Loads a plug-in module into the container. Loading consists of the }
* following steps:<ul>
* <p/> Log.debug( "Loading plugin '{}'...", pluginName );
* <li>Add all jars in the <tt>lib</tt> dir (if it exists) to the class loader</li> try
* <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> final Path pluginConfig = pluginDir.resolve( "plugin.xml" );
* <li>For each jive.module entry, load the given class as a module and start it</li> if ( !Files.exists( pluginConfig ) )
* <p/> {
* </ul> Log.warn( "Plugin '{}' could not be loaded: no plugin.xml file found.", pluginName );
* return;
* @param pluginDir the plugin directory. }
*/
private void loadPlugin(Path pluginDir) { final SAXReader saxReader = new SAXReader();
// Only load the admin plugin during setup mode. saxReader.setEncoding( "UTF-8" );
if (XMPPServer.getInstance().isSetupMode() && !(pluginDir.getFileName().toString().equals("admin"))) { final Document pluginXML = saxReader.read( pluginConfig.toFile() );
return;
} // See if the plugin specifies a version of Openfire required to run.
String pluginName = pluginDir.getFileName().toString(); final Element minServerVersion = (Element) pluginXML.selectSingleNode( "/plugin/minServerVersion" );
Log.debug("Loading plugin '{}'...", pluginName); if ( minServerVersion != null )
Plugin plugin; {
try { final Version requiredVersion = new Version( minServerVersion.getTextTrim() );
Path pluginConfig = pluginDir.resolve("plugin.xml"); final Version currentVersion = XMPPServer.getInstance().getServerInfo().getVersion();
if (Files.exists(pluginConfig)) { if ( requiredVersion.isNewerThan( currentVersion ) )
SAXReader saxReader = new SAXReader(); {
saxReader.setEncoding("UTF-8"); Log.warn( "Ignoring plugin '{}': requires server version {}. Current server version is {}.", pluginName, requiredVersion, currentVersion );
Document pluginXML = saxReader.read(pluginConfig.toFile()); return;
}
// See if the plugin specifies a version of Openfire }
// required to run.
Element minServerVersion = (Element)pluginXML.selectSingleNode("/plugin/minServerVersion"); // Properties to be used to load external resources. When set, plugin is considered to run in DEV mode.
if (minServerVersion != null) { final String devModeClassesDir = System.getProperty( pluginName + ".classes" );
Version requiredVersion = new Version(minServerVersion.getTextTrim()); final String devModewebRoot = System.getProperty( pluginName + ".webRoot" );
Version currentVersion = XMPPServer.getInstance().getServerInfo().getVersion(); final boolean devMode = devModewebRoot != null || devModeClassesDir != null;
if (requiredVersion.isNewerThan(currentVersion)) { final PluginDevEnvironment dev = ( devMode ? configurePluginDevEnvironment( pluginDir, devModeClassesDir, devModewebRoot ) : null );
Log.warn("Ignoring plugin '{}': requires server version {}. Current server version is {}.", pluginName, requiredVersion, currentVersion);
return; // 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
PluginClassLoader pluginLoader; // loader so that the plugins can interact.
String parentPluginName = null;
// Check to see if this is a child plugin of another plugin. If it is, we Plugin parentPlugin = null;
// re-use the parent plugin's class loader so that the plugins can interact. final Element parentPluginNode = (Element) pluginXML.selectSingleNode( "/plugin/parentPlugin" );
Element parentPluginNode = (Element)pluginXML.selectSingleNode("/plugin/parentPlugin"); if ( parentPluginNode != null )
{
String webRootKey = pluginName + ".webRoot"; // The name of the parent plugin as specified in plugin.xml might have incorrect casing. Lookup the correct name.
String classesDirKey = pluginName + ".classes"; for ( final Map.Entry<String, Plugin> entry : plugins.entrySet() )
String webRoot = System.getProperty(webRootKey); {
String classesDir = System.getProperty(classesDirKey); if ( entry.getKey().equalsIgnoreCase( parentPluginNode.getTextTrim() ) )
{
if (webRoot != null) { parentPluginName = entry.getKey();
final Path compilationClassesDir = pluginDir.resolve("classes"); parentPlugin = entry.getValue();
if (Files.notExists(compilationClassesDir)) { break;
Files.createDirectory(compilationClassesDir); }
} }
compilationClassesDir.toFile().deleteOnExit();
} // See if the parent is loaded.
if ( parentPlugin == null )
if (parentPluginNode != null) { {
String parentPlugin = parentPluginNode.getTextTrim(); Log.info( "Unable to load plugin '{}': parent plugin '{}' has not been loaded.", pluginName, parentPluginNode.getTextTrim() );
// See if the parent is already loaded. return;
if (plugins.containsKey(parentPlugin)) { }
pluginLoader = classloaders.get(getPlugin(parentPlugin)); pluginLoader = classloaders.get( parentPlugin );
pluginLoader.addDirectory(pluginDir.toFile(), classesDir != null); }
else
} {
else { // This is not a child plugin, so create a new class loader.
// See if the parent plugin exists but just hasn't been loaded yet. pluginLoader = new PluginClassLoader();
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" ) ) { // Add the plugin sources to the classloaded.
// Silently return. The child plugin will get loaded up on the next pluginLoader.addDirectory( pluginDir.toFile(), devMode );
// plugin load run after the parent.
return; // When running in DEV mode, add optional other sources too.
} else { if ( dev != null && dev.getClassesDir() != null )
Log.warn("Ignoring plugin '{}': parent plugin '{}' not present.", pluginName, parentPlugin); {
return; pluginLoader.addURLFile( dev.getClassesDir().toURI().toURL() );
} }
}
} // Instantiate the plugin!
// This is not a child plugin, so create a new class loader. final String className = pluginXML.selectSingleNode( "/plugin/class" ).getText().trim();
else { final Plugin plugin = (Plugin) pluginLoader.loadClass( className ).newInstance();
pluginLoader = new PluginClassLoader();
pluginLoader.addDirectory(pluginDir.toFile(), classesDir != null); // Bookkeeping!
} classloaders.put( plugin, pluginLoader );
plugins.put( pluginName, plugin );
// Check to see if development mode is turned on for the plugin. If it is, pluginDirs.put( plugin, pluginDir );
// configure dev mode. if ( dev != null )
{
PluginDevEnvironment dev = null; pluginDevelopment.put( plugin, dev );
if (webRoot != null || classesDir != null) { }
dev = new PluginDevEnvironment();
Log.info("Plugin '{}' is running in development mode.", pluginName); // If this is a child plugin, register it as such.
if (webRoot != null) { if ( parentPlugin != null )
Path webRootDir = Paths.get(webRoot); {
if (Files.notExists(webRootDir)) { List<String> childrenPlugins = parentPluginMap.get( parentPlugin );
// Ok, let's try it relative from this plugin dir? if ( childrenPlugins == null )
webRootDir = pluginDir.resolve(webRoot); {
} childrenPlugins = new ArrayList<>();
parentPluginMap.put( parentPlugin, childrenPlugins );
if (Files.exists(webRootDir)) { }
dev.setWebRoot(webRootDir.toFile()); childrenPlugins.add( pluginName );
}
} // Also register child to parent relationship.
childPluginMap.put( plugin, parentPluginName );
if (classesDir != null) { }
Path classes = Paths.get(classesDir);
if (Files.notExists(classes)) { // Check the plugin's database schema (if it requires one).
// ok, let's try it relative from this plugin dir? if ( !DbConnectionManager.getSchemaManager().checkPluginSchema( plugin ) )
classes = pluginDir.resolve(classesDir); {
} // The schema was not there and auto-upgrade failed.
Log.error( "Error while loading plugin '{}': {}", pluginName, LocaleUtils.getLocalizedString( "upgrade.database.failure" ) );
if (Files.exists(classes)) { }
dev.setClassesDir(classes.toFile());
pluginLoader.addURLFile(classes.toUri().toURL()); // 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() );
String className = pluginXML.selectSingleNode("/plugin/class").getText().trim(); }
plugin = (Plugin)pluginLoader.loadClass(className).newInstance();
if (parentPluginNode != null) { // Load any custom-defined servlets.
String parentPlugin = parentPluginNode.getTextTrim(); final Path customWebXML = pluginDir.resolve( "web" ).resolve( "WEB-INF" ).resolve( "web-custom.xml" );
// See if the parent is already loaded. if ( Files.exists( customWebXML ) )
if (plugins.containsKey(parentPlugin)) { {
pluginLoader = classloaders.get(getPlugin(parentPlugin)); PluginServlet.registerServlets( this, plugin, customWebXML.toFile() );
classloaders.put(plugin, pluginLoader); }
}
} // Configure caches of the plugin
configureCaches( pluginDir, pluginName );
plugins.put(pluginName, plugin);
pluginDirs.put(plugin, pluginDir); // Initialze the plugin.
final ClassLoader oldLoader = Thread.currentThread().getContextClassLoader();
// If this is a child plugin, register it as such. Thread.currentThread().setContextClassLoader( pluginLoader );
if (parentPluginNode != null) { plugin.initializePlugin( this, pluginDir.toFile() );
String parentPlugin = parentPluginNode.getTextTrim(); Log.debug( "Initialized plugin '{}'.", pluginName );
// The name of the parent plugin as specified in plugin.xml might have incorrect casing. Lookup the correct name. Thread.currentThread().setContextClassLoader( oldLoader );
for (Map.Entry<String, Plugin> entry : plugins.entrySet() ) {
if ( entry.getKey().equalsIgnoreCase( parentPlugin ) ) { // If there a <adminconsole> section defined, register it.
parentPlugin = entry.getKey(); final Element adminElement = (Element) pluginXML.selectSingleNode( "/plugin/adminconsole" );
break; if ( adminElement != null )
} {
} final Element appName = (Element) adminElement.selectSingleNode( "/plugin/adminconsole/global/appname" );
List<String> childrenPlugins = parentPluginMap.get(plugins.get(parentPlugin)); if ( appName != null )
if (childrenPlugins == null) { {
childrenPlugins = new ArrayList<>(); // Set the plugin name so that the proper i18n String can be loaded.
parentPluginMap.put(plugins.get(parentPlugin), childrenPlugins); appName.addAttribute( "plugin", pluginName );
} }
childrenPlugins.add(pluginName);
// Also register child to parent relationship. // If global images are specified, override their URL.
childPluginMap.put(plugin, parentPlugin); Element imageEl = (Element) adminElement.selectSingleNode( "/plugin/adminconsole/global/logo-image" );
} if ( imageEl != null )
else { {
// Only register the class loader in the case of this not being imageEl.setText( "plugins/" + pluginName + "/" + imageEl.getText() );
// a child plugin. imageEl.addAttribute( "plugin", pluginName ); // Set the plugin name so that the proper i18n String can be loaded.
classloaders.put(plugin, pluginLoader); }
} imageEl = (Element) adminElement.selectSingleNode( "/plugin/adminconsole/global/login-image" );
if ( imageEl != null )
// Check the plugin's database schema (if it requires one). {
if (!DbConnectionManager.getSchemaManager().checkPluginSchema(plugin)) { imageEl.setText( "plugins/" + pluginName + "/" + imageEl.getText() );
// The schema was not there and auto-upgrade failed. imageEl.addAttribute( "plugin", pluginName ); // Set the plugin name so that the proper i18n String can be loaded.
Log.error("Error while loading plugin '{}': {}", pluginName, LocaleUtils.getLocalizedString("upgrade.database.failure")); }
}
// Modify all the URL's in the XML so that they are passed through the plugin servlet correctly.
// Load any JSP's defined by the plugin. final List urls = adminElement.selectNodes( "//@url" );
Path webXML = pluginDir.resolve("web").resolve("WEB-INF").resolve("web.xml"); for ( final Object url : urls )
if (Files.exists(webXML)) { {
PluginServlet.registerServlets(this, plugin, webXML.toFile()); final Attribute attr = (Attribute) url;
} attr.setValue( "plugins/" + pluginName + "/" + attr.getValue() );
// Load any custom-defined servlets. }
Path customWebXML = pluginDir.resolve("web").resolve("WEB-INF").resolve("web-custom.xml");
if (Files.exists(customWebXML)) { // In order to internationalize the names and descriptions in the model, we add a "plugin" attribute to
PluginServlet.registerServlets(this, plugin, customWebXML.toFile()); // 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 )
if (dev != null) { {
pluginDevelopment.put(plugin, dev); final List values = adminElement.selectNodes( "//" + elementName );
} for ( final Object value : values )
{
// Configure caches of the plugin final Element element = (Element) value;
configureCaches(pluginDir, pluginName); // Make sure there's a name or description. Otherwise, no need to i18n settings.
if ( element.attribute( "name" ) != null || element.attribute( "value" ) != null )
// Init the plugin. {
ClassLoader oldLoader = Thread.currentThread().getContextClassLoader(); element.addAttribute( "plugin", pluginName );
Thread.currentThread().setContextClassLoader(pluginLoader); }
plugin.initializePlugin(this, pluginDir.toFile()); }
Log.debug("Initialized plugin '{}'.", pluginName); }
Thread.currentThread().setContextClassLoader(oldLoader);
AdminConsole.addModel( pluginName, adminElement );
// If there a <adminconsole> section defined, register it. }
Element adminElement = (Element)pluginXML.selectSingleNode("/plugin/adminconsole"); firePluginCreatedEvent( pluginName, plugin );
if (adminElement != null) { Log.info( "Successfully loaded plugin '{}'.", pluginName );
Element appName = (Element)adminElement.selectSingleNode( }
"/plugin/adminconsole/global/appname"); catch ( Throwable e )
if (appName != null) { {
// Set the plugin name so that the proper i18n String can be loaded. Log.error( "An exception occurred while loading plugin '{}':", pluginName, e );
appName.addAttribute("plugin", pluginName); }
} }
// If global images are specified, override their URL.
Element imageEl = (Element)adminElement.selectSingleNode( private PluginDevEnvironment configurePluginDevEnvironment( final Path pluginDir, String classesDir, String webRoot ) throws IOException
"/plugin/adminconsole/global/logo-image"); {
if (imageEl != null) { final String pluginName = pluginDir.getFileName().toString();
imageEl.setText("plugins/" + pluginName + "/" + imageEl.getText());
// Set the plugin name so that the proper i18n String can be loaded. final Path compilationClassesDir = pluginDir.resolve( "classes" );
imageEl.addAttribute("plugin", pluginName); if ( Files.notExists( compilationClassesDir ) )
} {
imageEl = (Element)adminElement.selectSingleNode("/plugin/adminconsole/global/login-image"); Files.createDirectory( compilationClassesDir );
if (imageEl != null) { }
imageEl.setText("plugins/" + pluginName + "/" + imageEl.getText()); compilationClassesDir.toFile().deleteOnExit();
// Set the plugin name so that the proper i18n String can be loaded.
imageEl.addAttribute("plugin", pluginName); final PluginDevEnvironment dev = new PluginDevEnvironment();
} Log.info( "Plugin '{}' is running in development mode.", pluginName );
// Modify all the URL's in the XML so that they are passed through if ( webRoot != null )
// the plugin servlet correctly. {
List urls = adminElement.selectNodes("//@url"); Path webRootDir = Paths.get( webRoot );
for (Object url : urls) { if ( Files.notExists( webRootDir ) )
Attribute attr = (Attribute)url; {
attr.setValue("plugins/" + pluginName + "/" + attr.getValue()); // Ok, let's try it relative from this plugin dir?
} webRootDir = pluginDir.resolve( webRoot );
// 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. if ( Files.exists( webRootDir ) )
String[] elementNames = new String [] { "tab", "sidebar", "item" }; {
for (String elementName : elementNames) { dev.setWebRoot( webRootDir.toFile() );
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 if ( classesDir != null )
// override i18n settings. {
if (element.attribute("name") != null || Path classes = Paths.get( classesDir );
element.attribute("value") != null) { if ( Files.notExists( classes ) )
element.addAttribute("plugin", pluginName); {
} // ok, let's try it relative from this plugin dir?
} classes = pluginDir.resolve( classesDir );
} }
AdminConsole.addModel(pluginName, adminElement); if ( Files.exists( classes ) )
} {
firePluginCreatedEvent(pluginName, plugin); dev.setClassesDir( classes.toFile() );
} }
else { }
Log.warn("Plugin '{}' could not be loaded: no plugin.xml file found.", pluginName);
} return dev;
} }
catch (Throwable e) {
Log.error("An exception occurred while loading plugin '{}':", pluginName, e); private void configureCaches( Path pluginDir, String pluginName )
} {
Log.info( "Successfully loaded plugin '{}'.", pluginName ); Path cacheConfig = pluginDir.resolve( "cache-config.xml" );
} if ( Files.exists( cacheConfig ) )
{
private void configureCaches(Path pluginDir, String pluginName) { PluginCacheConfigurator configurator = new PluginCacheConfigurator();
Path cacheConfig = pluginDir.resolve("cache-config.xml"); try
if (Files.exists(cacheConfig)) { {
PluginCacheConfigurator configurator = new PluginCacheConfigurator(); configurator.setInputStream( new BufferedInputStream( Files.newInputStream( cacheConfig ) ) );
try { configurator.configure( pluginName );
configurator.setInputStream(new BufferedInputStream(Files.newInputStream(cacheConfig))); }
configurator.configure(pluginName); catch ( Exception e )
} {
catch (Exception e) { Log.error( "An exception occurred while trying to configure caches for plugin '{}':", pluginName, e );
Log.error("An exception occurred while trying to configure caches for plugin '{}':", pluginName, e); }
} }
} }
}
/**
private void firePluginCreatedEvent(String name, Plugin plugin) { * Delete a plugin, which removes the plugin.jar/war file after which the plugin is unloaded.
for(PluginListener listener : pluginListeners) { */
listener.pluginCreated(name, plugin); public void deletePlugin( final String pluginName )
} {
} Log.debug( "Deleting plugin '{}'...", pluginName );
private void firePluginsMonitored() { try ( final DirectoryStream<Path> ds = Files.newDirectoryStream( getPluginsDirectory(), new DirectoryStream.Filter<Path>()
for(PluginManagerListener listener : pluginManagerListeners) { {
listener.pluginsMonitored(); @Override
} public boolean accept( final Path path ) throws IOException
} {
if ( Files.isDirectory( path ) )
/** {
* Unloads a plugin. The {@link Plugin#destroyPlugin()} method will be called and then return false;
* 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 final String fileName = path.getFileName().toString().toLowerCase();
* still exists after this method is called, the plugin will be started again the next return ( fileName.equals( pluginName + ".jar" ) || fileName.equals( pluginName + ".war" ) );
* 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> for ( final Path pluginFile : ds )
* {
* @param pluginName the name of the plugin to unload. try
*/ {
public void unloadPlugin(String pluginName) { Files.delete( pluginFile );
Log.debug("Unloading plugin '{}'...",pluginName); pluginMonitor.runNow( true ); // trigger unload by running the monitor (which is more thread-safe than calling unloadPlugin directly).
}
Plugin plugin = plugins.get(pluginName); catch ( IOException ex )
if (plugin != null) { {
// Remove from dev mode if it exists. Log.warn( "Unable to delete plugin '{}', as the plugin jar/war file cannot be deleted. File path: {}", pluginName, pluginFile, ex );
pluginDevelopment.remove(plugin); }
}
// See if any child plugins are defined. }
if (parentPluginMap.containsKey(plugin)) { catch ( Throwable e )
String[] childPlugins = {
parentPluginMap.get(plugin).toArray(new String[parentPluginMap.get(plugin).size()]); Log.error( "An unexpected exception occurred while deleting plugin '{}'.", pluginName, e );
parentPluginMap.remove(plugin); }
for (String childPlugin : childPlugins) { }
Log.debug("Unloading child plugin: '{}'.", childPlugin);
childPluginMap.remove(plugins.get(childPlugin)); /**
unloadPlugin(childPlugin); * 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.
*
Path webXML = pluginDirectory.resolve(pluginName).resolve("web").resolve("WEB-INF").resolve("web.xml"); * This method only removes the plugin but does not delete the plugin JAR file. Therefore, if the plugin JAR still
if (Files.exists(webXML)) { * exists after this method is called, the plugin will be started again the next time the plugin monitor process
AdminConsole.removeModel(pluginName); * runs. This is useful for "restarting" plugins. To completely remove the plugin, use {@link #deletePlugin(String)}
PluginServlet.unregisterServlets(webXML.toFile()); * instead.
} *
Path customWebXML = pluginDirectory.resolve(pluginName).resolve("web").resolve("WEB-INF").resolve("web-custom.xml"); * This method is called automatically when a plugin's JAR file is deleted.
if (Files.exists(customWebXML)) { *
PluginServlet.unregisterServlets(customWebXML.toFile()); * @param pluginName the name of the plugin to unload.
} */
public void unloadPlugin( String pluginName )
// 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 Log.debug( "Unloading plugin '{}'...", pluginName );
// 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 Plugin plugin = plugins.get( pluginName );
// resources. if ( plugin != null )
try { {
plugin.destroyPlugin(); // Remove from dev mode if it exists.
Log.debug( "Destroyed plugin '{}'.", pluginName ); pluginDevelopment.remove( plugin );
}
catch (Exception e) { // See if any child plugins are defined.
Log.error( "An exception occurred while unloading plugin '{}':", pluginName, e); if ( parentPluginMap.containsKey( plugin ) )
} {
} String[] childPlugins =
parentPluginMap.get( plugin ).toArray( new String[ parentPluginMap.get( plugin ).size() ] );
// Remove references to the plugin so it can be unloaded from memory parentPluginMap.remove( plugin );
// If plugin still fails to be removed then we will add references back for ( String childPlugin : childPlugins )
// 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 Log.debug( "Unloading child plugin: '{}'.", childPlugin );
plugins.remove(pluginName); childPluginMap.remove( plugins.get( childPlugin ) );
Path pluginFile = pluginDirs.remove(plugin); unloadPlugin( childPlugin );
PluginClassLoader pluginLoader = classloaders.remove(plugin); }
}
// try to close the cached jar files from the plugin class loader
if (pluginLoader != null) { Path webXML = pluginDirectory.resolve( pluginName ).resolve( "web" ).resolve( "WEB-INF" ).resolve( "web.xml" );
pluginLoader.unloadJarFiles(); if ( Files.exists( webXML ) )
} else { {
Log.warn("No plugin loader found for '{}'.",pluginName); AdminConsole.removeModel( pluginName );
} PluginServlet.unregisterServlets( webXML.toFile() );
}
// Try to remove the folder where the plugin was exploded. If this works then Path customWebXML = pluginDirectory.resolve( pluginName ).resolve( "web" ).resolve( "WEB-INF" ).resolve( "web-custom.xml" );
// the plugin was successfully removed. Otherwise, some objects created by the if ( Files.exists( customWebXML ) )
// plugin are still in memory. {
Path dir = pluginDirectory.resolve(pluginName); PluginServlet.unregisterServlets( customWebXML.toFile() );
// Give the plugin 2 seconds to unload. }
try {
Thread.sleep(2000); // Wrap destroying the plugin in a try/catch block. Otherwise, an exception raised
// Ask the system to clean up references. // in the destroy plugin process will disrupt the whole unloading process. It's still
System.gc(); // possible that classloader destruction won't work in the case that destroying the plugin
int count = 0; // fails. In that case, Openfire may need to be restarted to fully cleanup the plugin
while (!deleteDir(dir) && count++ < 5) { // resources.
Log.warn("Error unloading plugin '{}'. Will attempt again momentarily.", pluginName); try
Thread.sleep( 8000 ); {
// Ask the system to clean up references. plugin.destroyPlugin();
System.gc(); Log.debug( "Destroyed plugin '{}'.", pluginName );
} }
} catch (InterruptedException e) { catch ( Exception e )
Log.debug( "Stopped waiting for plugin '{}' to be fully unloaded.", pluginName, e ); {
} Log.error( "An exception occurred while unloading plugin '{}':", pluginName, e );
}
if (plugin != null && Files.notExists(dir)) { }
// Unregister plugin caches
PluginCacheRegistry.getInstance().unregisterCaches(pluginName); // 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
// See if this is a child plugin. If it is, we should unload // Anyway, for a few seconds admins may not see the plugin in the admin console
// the parent plugin as well. // and in a subsequent refresh it will appear if failed to be removed
if (childPluginMap.containsKey(plugin)) { plugins.remove( pluginName );
String parentPluginName = childPluginMap.get(plugin); Path pluginFile = pluginDirs.remove( plugin );
Plugin parentPlugin = plugins.get(parentPluginName); PluginClassLoader pluginLoader = classloaders.remove( plugin );
List<String> childrenPlugins = parentPluginMap.get(parentPlugin);
// try to close the cached jar files from the plugin class loader
childrenPlugins.remove(pluginName); if ( pluginLoader != null )
childPluginMap.remove(plugin); {
pluginLoader.unloadJarFiles();
// 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, else
// it's proper if the parent "gets informed first" about the dying child when the {
// child is the one being killed first. Log.warn( "No plugin loader found for '{}'.", pluginName );
if (parentPlugin instanceof PluginListener) { }
PluginListener listener;
listener = (PluginListener) parentPlugin; // Try to remove the folder where the plugin was exploded. If this works then
listener.pluginDestroyed(pluginName, plugin); // the plugin was successfully removed. Otherwise, some objects created by the
} // plugin are still in memory.
unloadPlugin(parentPluginName); Path dir = pluginDirectory.resolve( pluginName );
} // Give the plugin 2 seconds to unload.
firePluginDestroyedEvent(pluginName, plugin); try
Log.info("Successfully unloaded plugin '{}'.", pluginName); {
} Thread.sleep( 2000 );
else if (plugin != null) { // Ask the system to clean up references.
Log.info("Restore references since we failed to remove the plugin '{}'.", pluginName); System.gc();
plugins.put(pluginName, plugin); int count = 0;
pluginDirs.put(plugin, pluginFile); while ( !deleteDir( dir ) && count++ < 5 )
classloaders.put(plugin, pluginLoader); {
} Log.warn( "Error unloading plugin '{}'. Will attempt again momentarily.", pluginName );
} Thread.sleep( 8000 );
// Ask the system to clean up references.
private void firePluginDestroyedEvent(String name, Plugin plugin) { System.gc();
for (PluginListener listener : pluginListeners) { }
listener.pluginDestroyed(name, plugin); }
} catch ( InterruptedException e )
} {
Log.debug( "Stopped waiting for plugin '{}' to be fully unloaded.", pluginName, e );
/** }
* Loads a class from the classloader of a plugin.
* if ( plugin != null && Files.notExists( dir ) )
* @param plugin the plugin. {
* @param className the name of the class to load. // Unregister plugin caches
* @return the class. PluginCacheRegistry.getInstance().unregisterCaches( pluginName );
* @throws ClassNotFoundException if the class was not found.
* @throws IllegalAccessException if not allowed to access the class. // See if this is a child plugin. If it is, we should unload
* @throws InstantiationException if the class could not be created. // the parent plugin as well.
*/ if ( childPluginMap.containsKey( plugin ) )
public Class loadClass(Plugin plugin, String className) throws ClassNotFoundException, {
IllegalAccessException, InstantiationException { String parentPluginName = childPluginMap.get( plugin );
PluginClassLoader loader = classloaders.get(plugin); Plugin parentPlugin = plugins.get( parentPluginName );
return loader.loadClass(className); List<String> childrenPlugins = parentPluginMap.get( parentPlugin );
}
childrenPlugins.remove( pluginName );
/** childPluginMap.remove( plugin );
* Returns a plugin's dev environment if development mode is enabled for
* the 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,
* @param plugin the plugin. // it's proper if the parent "gets informed first" about the dying child when the
* @return the plugin dev environment, or <tt>null</tt> if development // child is the one being killed first.
* mode is not enabled for the plugin. if ( parentPlugin instanceof PluginListener )
*/ {
public PluginDevEnvironment getDevEnvironment(Plugin plugin) { PluginListener listener;
return pluginDevelopment.get(plugin); listener = (PluginListener) parentPlugin;
} listener.pluginDestroyed( pluginName, plugin );
}
/** unloadPlugin( parentPluginName );
* 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. firePluginDestroyedEvent( pluginName, plugin );
* Note that this value is distinct from the name of the plugin directory. Log.info( "Successfully unloaded plugin '{}'.", pluginName );
* }
* @param plugin the plugin. else if ( plugin != null )
* @return the plugin's name. {
*/ Log.info( "Restore references since we failed to remove the plugin '{}'.", pluginName );
public String getName(Plugin plugin) { plugins.put( pluginName, plugin );
String name = getElementValue(plugin, "/plugin/name"); pluginDirs.put( plugin, pluginFile );
String pluginName = pluginDirs.get(plugin).getFileName().toString(); classloaders.put( plugin, pluginLoader );
if (name != null) { }
return AdminConsole.getAdminText(name, pluginName); }
}
else { /**
return pluginName; * 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.
* Returns the description of a plugin. The value is retrieved from the plugin.xml file * @throws ClassNotFoundException if the class was not found.
* of the plugin. If the value could not be found, <tt>null</tt> will be returned. * @throws IllegalAccessException if not allowed to access the class.
* * @throws InstantiationException if the class could not be created.
* @param plugin the plugin. */
* @return the plugin's description. public Class loadClass( Plugin plugin, String className ) throws ClassNotFoundException,
*/ IllegalAccessException, InstantiationException
public String getDescription(Plugin plugin) { {
String pluginName = pluginDirs.get(plugin).getFileName().toString(); PluginClassLoader loader = classloaders.get( plugin );
return AdminConsole.getAdminText(getElementValue(plugin, "/plugin/description"), pluginName); return loader.loadClass( className );
} }
/** /**
* Returns the author of a plugin. The value is retrieved from the plugin.xml file * Returns a plugin's dev environment if development mode is enabled for
* of the plugin. If the value could not be found, <tt>null</tt> will be returned. * the plugin.
* *
* @param plugin the plugin. * @param plugin the plugin.
* @return the plugin's author. * @return the plugin dev environment, or <tt>null</tt> if development
*/ * mode is not enabled for the plugin.
public String getAuthor(Plugin plugin) { */
return getElementValue(plugin, "/plugin/author"); public PluginDevEnvironment getDevEnvironment( Plugin plugin )
} {
return pluginDevelopment.get( 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. /**
* * @deprecated Moved to {@link PluginMetadataHelper#getName(Plugin)}.
* @param plugin the plugin. */
* @return the plugin's version. @Deprecated
*/ public String getName( Plugin plugin )
public String getVersion(Plugin plugin) { {
return getElementValue(plugin, "/plugin/version"); return PluginMetadataHelper.getName( plugin );
} }
/** /**
* Returns the minimum server version this plugin can run within. The value is retrieved from the plugin.xml file * @deprecated Moved to {@link PluginMetadataHelper#getDescription(Plugin)}.
* of the plugin. If the value could not be found, <tt>null</tt> will be returned. */
* @Deprecated
* @param plugin the plugin. public String getDescription( Plugin plugin )
* @return the plugin's version. {
*/ return PluginMetadataHelper.getDescription( plugin );
public String getMinServerVersion(Plugin plugin) { }
return getElementValue(plugin, "/plugin/minServerVersion");
} /**
* @deprecated Moved to {@link PluginMetadataHelper#getAuthor(Plugin)}.
/** */
* Returns the database schema key of a plugin, if it exists. The value is retrieved @Deprecated
* from the plugin.xml file of the plugin. If the value could not be found, <tt>null</tt> public String getAuthor( Plugin plugin )
* will be returned. {
* return PluginMetadataHelper.getAuthor( plugin );
* @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) { * @deprecated Moved to {@link PluginMetadataHelper#getVersion(Plugin)}.
return getElementValue(plugin, "/plugin/databaseKey"); */
} @Deprecated
public String getVersion( Plugin plugin )
/** {
* Returns the database schema version of a plugin, if it exists. The value is retrieved return PluginMetadataHelper.getVersion( plugin );
* 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. * @deprecated Moved to {@link PluginMetadataHelper#getMinServerVersion(Plugin)}.
* @return the plugin's database schema version or <tt>-1</tt> if it doesn't exist. */
*/ @Deprecated
public int getDatabaseVersion(Plugin plugin) { public String getMinServerVersion( Plugin plugin )
String versionString = getElementValue(plugin, "/plugin/databaseVersion"); {
if (versionString != null) { return PluginMetadataHelper.getMinServerVersion( plugin );
try { }
return Integer.parseInt(versionString.trim());
} /**
catch (NumberFormatException nfe) { * @deprecated Moved to {@link PluginMetadataHelper#getDatabaseKey(Plugin)}.
Log.error("Unable to parse the database version for plugin '{}'.", getName( plugin ), nfe); */
} @Deprecated
} public String getDatabaseKey( Plugin plugin )
return -1; {
} return PluginMetadataHelper.getDatabaseKey( 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 * @deprecated Moved to {@link PluginMetadataHelper#getDatabaseVersion(Plugin)}.
* found, {@link License#other} is returned. */
* @Deprecated
* @param plugin the plugin. public int getDatabaseVersion( Plugin plugin )
* @return the plugin's license agreement. {
*/ return PluginMetadataHelper.getDatabaseVersion( plugin );
public License getLicense(Plugin plugin) { }
String licenseString = getElementValue(plugin, "/plugin/licenseType");
if (licenseString != null) { /**
try { * @deprecated Moved to {@link PluginMetadataHelper#getLicense(Plugin)}.
// 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 @Deprecated
// license type is not recognized, we'll log the error and default public License getLicense( Plugin plugin )
// to "other". {
return License.valueOf(licenseString.toLowerCase().trim()); return PluginMetadataHelper.getLicense( plugin );
} }
catch (IllegalArgumentException iae) {
Log.error("Unrecognized license type '{}' for plugin '{}'.", licenseString.toLowerCase().trim(), getName( plugin ), iae); /**
} * Returns the classloader of a plugin.
} *
return License.other; * @param plugin the plugin.
} * @return the classloader of the plugin.
*/
/** public PluginClassLoader getPluginClassloader( Plugin plugin )
* Returns the classloader of a plugin. {
* return classloaders.get( 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.
* Returns the value of an element selected via an xpath expression from */
* a Plugin's plugin.xml file. static boolean deleteDir( Path dir )
* {
* @param plugin the plugin. try
* @param xpath the xpath expression. {
* @return the value of the element selected by the xpath expression. if ( Files.isDirectory( dir ) )
*/ {
private String getElementValue(Plugin plugin, String xpath) { Files.walkFileTree( dir, new SimpleFileVisitor<Path>()
Path pluginDir = pluginDirs.get(plugin); {
if (pluginDir == null) { @Override
return null; public FileVisitResult visitFile( Path file, BasicFileAttributes attrs ) throws IOException
} {
try { try
Path pluginConfig = pluginDir.resolve("plugin.xml"); {
if (Files.exists(pluginConfig)) { Files.deleteIfExists( file );
SAXReader saxReader = new SAXReader(); }
saxReader.setEncoding("UTF-8"); catch ( IOException e )
Document pluginXML = saxReader.read(pluginConfig.toFile()); {
Element element = (Element)pluginXML.selectSingleNode(xpath); Log.debug( "Plugin removal: could not delete: {}", file );
if (element != null) { throw e;
return element.getTextTrim(); }
} return FileVisitResult.CONTINUE;
} }
}
catch (Exception e) { @Override
Log.error("Unable to get element value '{}' from plugin.xml of plugin '{}':", xpath, getName(plugin), e); public FileVisitResult postVisitDirectory( Path dir, IOException exc ) throws IOException
} {
return null; try
} {
Files.deleteIfExists( dir );
/** }
* An enumberation for plugin license agreement types. catch ( IOException e )
*/ {
@SuppressWarnings({"UnnecessarySemicolon"}) // Support for QDox Parser Log.debug( "Plugin removal: could not delete: {}", dir );
public enum License { throw e;
}
/** return FileVisitResult.CONTINUE;
* The plugin is distributed using a commercial license. }
*/ } );
commercial, }
return Files.notExists( dir ) || Files.deleteIfExists( dir );
/** }
* The plugin is distributed using the GNU Public License (GPL). catch ( IOException e )
*/ {
gpl, return Files.notExists( dir );
}
/** }
* The plugin is distributed using the Apache license.
*/ /**
apache, * 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.
* The plugin is for internal use at an organization only and is not re-distributed. *
*/ * @param listener the listener to be notified (cannot be null).
internal, */
public void addPluginListener( PluginListener listener )
/** {
* The plugin is distributed under another license agreement not covered by pluginListeners.add( listener );
* one of the other choices. The license agreement should be detailed in the }
* plugin Readme.
*/ /**
other; * Deregisters a PluginListener, which will no longer receive events.
} *
* When the listener was never added, this method will have no effect.
/** *
* A service that monitors the plugin directory for plugins. It periodically * @param listener the listener to be removed (cannot be null).
* checks for new plugin JAR files and extracts them if they haven't already */
* been extracted. Then, any new plugin directories are loaded. public void removePluginListener( PluginListener listener )
*/ {
private class PluginMonitor implements Runnable { pluginListeners.remove( listener );
}
/**
* Tracks if the monitor is currently running. /**
*/ * Registers a PluginManagerListener, which will now start receiving events regarding plugin management.
private boolean running = false; *
* @param listener the listener to be notified (cannot be null).
/** */
* True if the monitor has been executed at least once. After the first iteration in {@link #run} public void addPluginManagerListener( PluginManagerListener listener )
* this variable will always be true. {
* */ pluginManagerListeners.add( listener );
private boolean executed = false; if ( isExecuted() )
{
/** firePluginsMonitored();
* True when it's the first time the plugin monitor process runs. This is helpful for }
* bootstrapping purposes. }
*/
private boolean firstRun = true; /**
* Deregisters a PluginManagerListener, which will no longer receive events.
@Override *
public void run() { * When the listener was never added, this method will have no effect.
// If the task is already running, return. *
synchronized (this) { * @param listener the listener to be notified (cannot be null).
if (running) { */
return; public void removePluginManagerListener( PluginManagerListener listener )
} {
running = true; pluginManagerListeners.remove( listener );
} }
try {
running = true; /**
// Look for extra plugin directories specified as a system property. * Notifies all registered PluginListener instances that a new plugin was created.
String pluginDirs = System.getProperty("pluginDirs"); *
if (pluginDirs != null) { * @param name The name of the plugin
StringTokenizer st = new StringTokenizer(pluginDirs, ", "); * @param plugin the plugin.
while (st.hasMoreTokens()) { */
String dir = st.nextToken(); void firePluginCreatedEvent( String name, Plugin plugin )
if (!devPlugins.contains(dir)) { {
loadPlugin(Paths.get(dir)); for ( final PluginListener listener : pluginListeners )
devPlugins.add(dir); {
} try
} {
} listener.pluginCreated( name, plugin );
}
// Turn the list of JAR/WAR files into a set so that we can do lookups. catch ( Exception ex )
Set<String> jarSet = new HashSet<>(); {
Log.warn( "An exception was thrown when one of the pluginManagerListeners was notified of a 'created' event for plugin '{}'!", name, ex );
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")); /**
} * Notifies all registered PluginListener instances that a plugin was destroyed.
})) { *
for (Path jarFile : directoryStream) { * @param name The name of the plugin
jarSet.add(jarFile.getFileName().toString().toLowerCase()); * @param plugin the plugin.
String pluginName = jarFile.getFileName().toString().substring(0, */
jarFile.getFileName().toString().length() - 4).toLowerCase(); void firePluginDestroyedEvent( String name, Plugin plugin )
// See if the JAR has already been exploded. {
Path dir = pluginDirectory.resolve(pluginName); for ( final PluginListener listener : pluginListeners )
// Store the JAR/WAR file that created the plugin folder {
pluginFiles.put(pluginName, jarFile); try
// If the JAR hasn't been exploded, do so. {
if (Files.notExists(dir)) { listener.pluginDestroyed( name, plugin );
unzipPlugin(pluginName, jarFile, dir); }
} catch ( Exception ex )
// See if the JAR is newer than the directory. If so, the plugin {
// needs to be unloaded and then reloaded. Log.warn( "An exception was thrown when one of the pluginManagerListeners was notified of a 'destroyed' event for plugin '{}'!", name, ex );
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) { * Notifies all registered PluginManagerListener instances that the service monitoring for plugin changes completed a
Thread.sleep(1000); * periodic check.
} */
} void firePluginsMonitored()
else { {
unloadPlugin(pluginName); // Set that at least one iteration was done. That means that "all available" plugins
} // have been loaded by now.
// If the delete operation was a success, unzip the plugin. if ( !XMPPServer.getInstance().isSetupMode() )
if (Files.notExists(dir)) { {
unzipPlugin(pluginName, jarFile, dir); executed = true;
} }
}
} for ( final PluginManagerListener listener : pluginManagerListeners )
} {
try
List<Path> dirs = new ArrayList<>(); {
listener.pluginsMonitored();
try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(pluginDirectory, new DirectoryStream.Filter<Path>() { }
@Override catch ( Exception ex )
public boolean accept(Path pathname) throws IOException { {
return Files.isDirectory(pathname); Log.warn( "An exception was thrown when one of the pluginManagerListeners was notified of a 'monitored' event!", ex );
} }
})) { }
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);
}
} }
\ 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 @@ ...@@ -85,14 +85,7 @@
} }
if (csrf_check && deletePlugin != null) { if (csrf_check && deletePlugin != null) {
File pluginDir = pluginManager.getPluginDirectory(pluginManager.getPlugin(deletePlugin)); pluginManager.deletePlugin( 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());
// Log the event // Log the event
webManager.logEvent("deleted plugin "+deletePlugin, null); webManager.logEvent("deleted plugin "+deletePlugin, null);
response.sendRedirect("plugin-admin.jsp?deletesuccess=true"); 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