Commit 61f078ae authored by Dave Cridland's avatar Dave Cridland Committed by GitHub

Merge pull request #598 from guusdk/OF-1147_Plugin-Management-refactoring

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