PluginManager.java 49.8 KB
Newer Older
1 2 3 4 5
/**
 * $RCSfile$
 * $Revision: 3001 $
 * $Date: 2005-10-31 05:39:25 -0300 (Mon, 31 Oct 2005) $
 *
6
 * Copyright (C) 2004-2008 Jive Software. All rights reserved.
7 8
 *
 * This software is published under the terms of the GNU Public License (GPL),
9 10
 * a copy of which is included in this distribution, or a commercial license
 * agreement with Jive.
11 12
 */

13
package org.jivesoftware.openfire.container;
14 15 16 17 18 19

import org.dom4j.Attribute;
import org.dom4j.Document;
import org.dom4j.Element;
import org.dom4j.io.SAXReader;
import org.jivesoftware.admin.AdminConsole;
20
import org.jivesoftware.database.DbConnectionManager;
21
import org.jivesoftware.openfire.XMPPServer;
22
import org.jivesoftware.util.LocaleUtils;
23 24 25
import org.jivesoftware.util.Log;
import org.jivesoftware.util.Version;

26 27
import java.io.*;
import java.util.*;
Matt Tucker's avatar
Matt Tucker committed
28
import java.util.concurrent.*;
29 30
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
31 32
import java.util.jar.JarOutputStream;
import java.util.jar.Pack200;
33 34 35 36
import java.util.zip.ZipFile;

/**
 * Loads and manages plugins. The <tt>plugins</tt> directory is monitored for any
Matt Tucker's avatar
Matt Tucker committed
37 38 39 40
 * new plugins, and they are dynamically loaded.<p/>
 *
 * An instance of this class can be obtained using:<p/>
 *
41 42 43 44
 * <tt>XMPPServer.getInstance().getPluginManager()</tt>
 *
 * @author Matt Tucker
 * @see Plugin
45
 * @see org.jivesoftware.openfire.XMPPServer#getPluginManager()
46 47 48 49 50 51 52
 */
public class PluginManager {

    private File pluginDirectory;
    private Map<String, Plugin> plugins;
    private Map<Plugin, PluginClassLoader> classloaders;
    private Map<Plugin, File> pluginDirs;
53 54 55 56
    /**
     * Keep track of plugin names and their unzipped files. This list is updated when plugin
     * is exploded and not when is loaded.
     */
57
    private Map<String, File> pluginFiles;
58 59 60 61 62
    private ScheduledExecutorService executor = null;
    private Map<Plugin, PluginDevEnvironment> pluginDevelopment;
    private Map<Plugin, List<String>> parentPluginMap;
    private Map<Plugin, String> childPluginMap;
    private Set<String> devPlugins;
63
    private PluginMonitor pluginMonitor;
Matt Tucker's avatar
Matt Tucker committed
64
    private Set<PluginListener> pluginListeners = new CopyOnWriteArraySet<PluginListener>();
65
    private Set<PluginManagerListener> pluginManagerListeners = new CopyOnWriteArraySet<PluginManagerListener>();
66 67 68 69 70 71 72 73 74 75

    /**
     * Constructs a new plugin manager.
     *
     * @param pluginDir the plugin directory.
     */
    public PluginManager(File pluginDir) {
        this.pluginDirectory = pluginDir;
        plugins = new ConcurrentHashMap<String, Plugin>();
        pluginDirs = new HashMap<Plugin, File>();
76
        pluginFiles = new HashMap<String, File>();
77 78 79 80 81
        classloaders = new HashMap<Plugin, PluginClassLoader>();
        pluginDevelopment = new HashMap<Plugin, PluginDevEnvironment>();
        parentPluginMap = new HashMap<Plugin, List<String>>();
        childPluginMap = new HashMap<Plugin, String>();
        devPlugins = new HashSet<String>();
82
        pluginMonitor = new PluginMonitor();
83 84 85 86 87 88 89
    }

    /**
     * Starts plugins and the plugin monitoring service.
     */
    public void start() {
        executor = new ScheduledThreadPoolExecutor(1);
Matt Tucker's avatar
Matt Tucker committed
90
        // See if we're in development mode. If so, check for new plugins once every 5 seconds.
91
        // Otherwise, default to every 20 seconds.
Matt Tucker's avatar
Matt Tucker committed
92
        if (Boolean.getBoolean("developmentMode")) {
Matt Tucker's avatar
Matt Tucker committed
93
            executor.scheduleWithFixedDelay(pluginMonitor, 0, 5, TimeUnit.SECONDS);
Matt Tucker's avatar
Matt Tucker committed
94 95
        }
        else {
Matt Tucker's avatar
Matt Tucker committed
96
            executor.scheduleWithFixedDelay(pluginMonitor, 0, 20, TimeUnit.SECONDS);
Matt Tucker's avatar
Matt Tucker committed
97
        }
98 99 100 101 102 103 104 105 106 107 108 109
    }

    /**
     * Shuts down all running plugins.
     */
    public void shutdown() {
        // Stop the plugin monitoring service.
        if (executor != null) {
            executor.shutdown();
        }
        // Shutdown all installed plugins.
        for (Plugin plugin : plugins.values()) {
110 111 112 113 114 115
            try {
                plugin.destroyPlugin();
            }
            catch (Exception e) {
                Log.error(e);
            }
116 117 118
        }
        plugins.clear();
        pluginDirs.clear();
119
        pluginFiles.clear();
120 121 122
        classloaders.clear();
        pluginDevelopment.clear();
        childPluginMap.clear();
123
        pluginMonitor = null;
124 125
    }

126 127 128 129 130 131 132 133
    /**
     * Installs or updates an existing plugin.
     *
     * @param in the input stream that contains the new plugin definition.
     * @param pluginFilename the filename of the plugin to create or update.
     * @return true if the plugin was successfully installed or updated.
     */
    public boolean installPlugin(InputStream in, String pluginFilename) {
134 135 136 137
        if (in == null || pluginFilename == null || pluginFilename.length() < 1) {
            Log.error("Error installing plugin: Input stream was null or pluginFilename was null or had no length.");
            return false;
        }
138 139 140
        try {
            byte[] b = new byte[1024];
            int len;
141 142 143 144 145
            // If pluginFilename is a path instead of a simple file name, we only want the file name
            int index = pluginFilename.lastIndexOf(File.separator);
            if (index != -1) {
                pluginFilename = pluginFilename.substring(index+1);
            }
146 147 148 149 150 151 152 153 154 155 156 157 158
            // Absolute path to the plugin file
            String absolutePath = pluginDirectory + File.separator + pluginFilename;
            // Save input stream contents to a temp file
            OutputStream out = new FileOutputStream(absolutePath + ".part");
            while ((len = in.read(b)) != -1) {
                     //write byte to file
                     out.write(b, 0, len);
            }
            out.close();
            // Delete old .jar (if it exists)
            new File(absolutePath).delete();
            // Rename temp file to .jar
            new File(absolutePath + ".part").renameTo(new File(absolutePath));
159 160
            // Ask the plugin monitor to update the plugin immediately.
            pluginMonitor.run();
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178
        }
        catch (IOException e) {
            Log.error("Error installing new version of plugin: " + pluginFilename, e);
            return false;
        }
        return true;
    }

    /**
     * Returns true if the specified filename, that belongs to a plugin, exists.
     *
     * @param pluginFilename the filename of the plugin to create or update.
     * @return true if the specified filename, that belongs to a plugin, exists.
     */
    public boolean isPluginDownloaded(String pluginFilename) {
        return new File(pluginDirectory + File.separator + pluginFilename).exists();
    }

179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209
    /**
     * Returns a Collection of all installed plugins.
     *
     * @return a Collection of all installed plugins.
     */
    public Collection<Plugin> getPlugins() {
        return Collections.unmodifiableCollection(plugins.values());
    }

    /**
     * Returns a plugin by name or <tt>null</tt> if a plugin with that name does not
     * exist. The name is the name of the directory that the plugin is in such as
     * "broadcast".
     *
     * @param name the name of the plugin.
     * @return the plugin.
     */
    public Plugin getPlugin(String name) {
        return plugins.get(name);
    }

    /**
     * Returns the plugin's directory.
     *
     * @param plugin the plugin.
     * @return the plugin's directory.
     */
    public File getPluginDirectory(Plugin plugin) {
        return pluginDirs.get(plugin);
    }

210 211 212 213 214 215 216 217 218 219
    /**
     * Returns the JAR or WAR file that created the plugin.
     *
     * @param name the name of the plugin.
     * @return the plugin JAR or WAR file.
     */
    public File getPluginFile(String name) {
        return pluginFiles.get(name);
    }

220 221 222 223 224 225 226 227 228 229 230 231 232 233
    /**
     * Returns true if at least one attempt to load plugins has been done. A true value does not mean
     * that available plugins have been loaded nor that plugins to be added in the future are already
     * loaded. :)<p>
     *
     * TODO Current version does not consider child plugins that may be loaded in a second attempt. It either
     * TODO consider plugins that were found but failed to be loaded due to some error.
     *
     * @return true if at least one attempt to load plugins has been done.
     */
    public boolean isExecuted() {
        return pluginMonitor.executed;
    }

234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251
    /**
     * Loads a plug-in module into the container. Loading consists of the
     * following steps:<ul>
     * <p/>
     * <li>Add all jars in the <tt>lib</tt> dir (if it exists) to the class loader</li>
     * <li>Add all files in <tt>classes</tt> dir (if it exists) to the class loader</li>
     * <li>Locate and load <tt>module.xml</tt> into the context</li>
     * <li>For each jive.module entry, load the given class as a module and start it</li>
     * <p/>
     * </ul>
     *
     * @param pluginDir the plugin directory.
     */
    private void loadPlugin(File pluginDir) {
        // Only load the admin plugin during setup mode.
        if (XMPPServer.getInstance().isSetupMode() && !(pluginDir.getName().equals("admin"))) {
            return;
        }
252
        Log.debug("PluginManager: Loading plugin " + pluginDir.getName());
Matt Tucker's avatar
Matt Tucker committed
253
        Plugin plugin;
254 255 256 257
        try {
            File pluginConfig = new File(pluginDir, "plugin.xml");
            if (pluginConfig.exists()) {
                SAXReader saxReader = new SAXReader();
258
                saxReader.setEncoding("UTF-8");
259 260
                Document pluginXML = saxReader.read(pluginConfig);

261
                // See if the plugin specifies a version of Openfire
262 263 264 265 266 267
                // required to run.
                Element minServerVersion = (Element)pluginXML.selectSingleNode("/plugin/minServerVersion");
                if (minServerVersion != null) {
                    String requiredVersion = minServerVersion.getTextTrim();
                    Version version = XMPPServer.getInstance().getServerInfo().getVersion();
                    String hasVersion = version.getMajor() + "." + version.getMinor() + "." +
268
                        version.getMicro();
269 270
                    if (hasVersion.compareTo(requiredVersion) < 0) {
                        String msg = "Ignoring plugin " + pluginDir.getName() + ": requires " +
271
                            "server version " + requiredVersion;
272 273 274 275 276 277 278 279 280 281 282
                        Log.warn(msg);
                        System.out.println(msg);
                        return;
                    }
                }

                PluginClassLoader pluginLoader;

                // Check to see if this is a child plugin of another plugin. If it is, we
                // re-use the parent plugin's class loader so that the plugins can interact.
                Element parentPluginNode = (Element)pluginXML.selectSingleNode("/plugin/parentPlugin");
283 284 285 286 287 288

                String pluginName = pluginDir.getName();
                String webRootKey = pluginName + ".webRoot";
                String classesDirKey = pluginName + ".classes";
                String webRoot = System.getProperty(webRootKey);
                String classesDir = System.getProperty(classesDirKey);
289

290
                if (webRoot != null) {
291
                    final File compilationClassesDir = new File(pluginDir, "classes");
292
                    if (!compilationClassesDir.exists()) {
293 294
                        compilationClassesDir.mkdir();
                    }
295
                    compilationClassesDir.deleteOnExit();
296
                }
297

298 299 300 301 302
                if (parentPluginNode != null) {
                    String parentPlugin = parentPluginNode.getTextTrim();
                    // See if the parent is already loaded.
                    if (plugins.containsKey(parentPlugin)) {
                        pluginLoader = classloaders.get(getPlugin(parentPlugin));
303
                        pluginLoader.addDirectory(pluginDir, classesDir != null);
304 305 306 307 308 309

                    }
                    else {
                        // See if the parent plugin exists but just hasn't been loaded yet.
                        // This can only be the case if this plugin name is alphabetically before
                        // the parent.
310
                        if (pluginName.compareTo(parentPlugin) < 0) {
311 312 313 314 315 316 317 318 319 320 321 322 323 324 325
                            // See if the parent exists.
                            File file = new File(pluginDir.getParentFile(), parentPlugin + ".jar");
                            if (file.exists()) {
                                // Silently return. The child plugin will get loaded up on the next
                                // plugin load run after the parent.
                                return;
                            }
                            else {
                                file = new File(pluginDir.getParentFile(), parentPlugin + ".war");
                                if (file.exists()) {
                                    // Silently return. The child plugin will get loaded up on the next
                                    // plugin load run after the parent.
                                    return;
                                }
                                else {
326
                                    String msg = "Ignoring plugin " + pluginName + ": parent plugin " +
327
                                        parentPlugin + " not present.";
328 329 330 331 332 333 334
                                    Log.warn(msg);
                                    System.out.println(msg);
                                    return;
                                }
                            }
                        }
                        else {
335
                            String msg = "Ignoring plugin " + pluginName + ": parent plugin " +
336 337 338 339 340 341 342 343 344
                                parentPlugin + " not present.";
                            Log.warn(msg);
                            System.out.println(msg);
                            return;
                        }
                    }
                }
                // This is not a child plugin, so create a new class loader.
                else {
345
                    pluginLoader = new PluginClassLoader();
346
                    pluginLoader.addDirectory(pluginDir, classesDir != null);
347 348 349 350 351
                }

                // Check to see if development mode is turned on for the plugin. If it is,
                // configure dev mode.

352
                PluginDevEnvironment dev = null;
353
                if (webRoot != null || classesDir != null) {
354 355
                    dev = new PluginDevEnvironment();

Matt Tucker's avatar
Matt Tucker committed
356 357
                    System.out.println("Plugin " + pluginName + " is running in development mode.");
                    Log.info("Plugin " + pluginName + " is running in development mode.");
358 359 360
                    if (webRoot != null) {
                        File webRootDir = new File(webRoot);
                        if (!webRootDir.exists()) {
Matt Tucker's avatar
Matt Tucker committed
361
                            // Ok, let's try it relative from this plugin dir?
362 363
                            webRootDir = new File(pluginDir, webRoot);
                        }
364

365 366 367
                        if (webRootDir.exists()) {
                            dev.setWebRoot(webRootDir);
                        }
368 369
                    }

370 371 372 373 374 375
                    if (classesDir != null) {
                        File classes = new File(classesDir);
                        if (!classes.exists()) {
                            // ok, let's try it relative from this plugin dir?
                            classes = new File(pluginDir, classesDir);
                        }
376

377 378
                        if (classes.exists()) {
                            dev.setClassesDir(classes);
379
                            pluginLoader.addURLFile(classes.getAbsoluteFile().toURL());
380
                        }
381 382 383
                    }
                }

384
                String className = pluginXML.selectSingleNode("/plugin/class").getText().trim();
385
                plugin = (Plugin)pluginLoader.loadClass(className).newInstance();
386
                if (parentPluginNode != null) {
387 388 389 390 391 392 393 394
                    String parentPlugin = parentPluginNode.getTextTrim();
                    // See if the parent is already loaded.
                    if (plugins.containsKey(parentPlugin)) {
                        pluginLoader = classloaders.get(getPlugin(parentPlugin));
                        classloaders.put(plugin, pluginLoader);
                    }
                }

395
                plugins.put(pluginName, plugin);
396 397 398 399 400 401 402 403 404 405
                pluginDirs.put(plugin, pluginDir);

                // If this is a child plugin, register it as such.
                if (parentPluginNode != null) {
                    String parentPlugin = parentPluginNode.getTextTrim();
                    List<String> childrenPlugins = parentPluginMap.get(plugins.get(parentPlugin));
                    if (childrenPlugins == null) {
                        childrenPlugins = new ArrayList<String>();
                        parentPluginMap.put(plugins.get(parentPlugin), childrenPlugins);
                    }
406
                    childrenPlugins.add(pluginName);
407 408 409 410 411 412 413 414 415
                    // Also register child to parent relationship.
                    childPluginMap.put(plugin, parentPlugin);
                }
                else {
                    // Only register the class loader in the case of this not being
                    // a child plugin.
                    classloaders.put(plugin, pluginLoader);
                }

416
                // Check the plugin's database schema (if it requires one).
417 418
                if (!DbConnectionManager.getSchemaManager().checkPluginSchema(plugin)) {
                    // The schema was not there and auto-upgrade failed.
419 420 421 422
                    Log.error(pluginName + " - " +
                            LocaleUtils.getLocalizedString("upgrade.database.failure"));
                    System.out.println(pluginName + " - " +
                            LocaleUtils.getLocalizedString("upgrade.database.failure"));
423
                }
424

425 426
                // Load any JSP's defined by the plugin.
                File webXML = new File(pluginDir, "web" + File.separator + "WEB-INF" +
427
                    File.separator + "web.xml");
428 429 430 431 432
                if (webXML.exists()) {
                    PluginServlet.registerServlets(this, plugin, webXML);
                }
                // Load any custom-defined servlets.
                File customWebXML = new File(pluginDir, "web" + File.separator + "WEB-INF" +
433
                    File.separator + "web-custom.xml");
434 435 436 437 438 439 440 441
                if (customWebXML.exists()) {
                    PluginServlet.registerServlets(this, plugin, customWebXML);
                }

                if (dev != null) {
                    pluginDevelopment.put(plugin, dev);
                }

442 443 444
                // Configure caches of the plugin
                configureCaches(pluginDir, pluginName);

445
                // Init the plugin.
446 447
                ClassLoader oldLoader = Thread.currentThread().getContextClassLoader();
                Thread.currentThread().setContextClassLoader(pluginLoader);
448
                plugin.initializePlugin(this, pluginDir);
449
                Thread.currentThread().setContextClassLoader(oldLoader);
450

451 452 453
                // If there a <adminconsole> section defined, register it.
                Element adminElement = (Element)pluginXML.selectSingleNode("/plugin/adminconsole");
                if (adminElement != null) {
Matt Tucker's avatar
Matt Tucker committed
454 455 456 457 458 459
                    Element appName = (Element)adminElement.selectSingleNode(
                        "/plugin/adminconsole/global/appname");
                    if (appName != null) {
                        // Set the plugin name so that the proper i18n String can be loaded.
                        appName.addAttribute("plugin", pluginName);
                    }
460 461
                    // If global images are specified, override their URL.
                    Element imageEl = (Element)adminElement.selectSingleNode(
462
                        "/plugin/adminconsole/global/logo-image");
463 464
                    if (imageEl != null) {
                        imageEl.setText("plugins/" + pluginName + "/" + imageEl.getText());
Matt Tucker's avatar
Matt Tucker committed
465 466
                        // Set the plugin name so that the proper i18n String can be loaded.
                        imageEl.addAttribute("plugin", pluginName);
467 468 469 470
                    }
                    imageEl = (Element)adminElement.selectSingleNode("/plugin/adminconsole/global/login-image");
                    if (imageEl != null) {
                        imageEl.setText("plugins/" + pluginName + "/" + imageEl.getText());
Matt Tucker's avatar
Matt Tucker committed
471 472
                        // Set the plugin name so that the proper i18n String can be loaded.
                        imageEl.addAttribute("plugin", pluginName);
473 474 475 476
                    }
                    // Modify all the URL's in the XML so that they are passed through
                    // the plugin servlet correctly.
                    List urls = adminElement.selectNodes("//@url");
Matt Tucker's avatar
Matt Tucker committed
477
                    for (Object url : urls) {
478
                        Attribute attr = (Attribute)url;
479 480
                        attr.setValue("plugins/" + pluginName + "/" + attr.getValue());
                    }
Matt Tucker's avatar
Matt Tucker committed
481 482 483 484
                    // In order to internationalize the names and descriptions in the model,
                    // we add a "plugin" attribute to each tab, sidebar, and item so that
                    // the the renderer knows where to load the i18n Strings from.
                    String[] elementNames = new String [] { "tab", "sidebar", "item" };
485 486
                    for (String elementName : elementNames) {
                        List values = adminElement.selectNodes("//" + elementName);
Matt Tucker's avatar
Matt Tucker committed
487
                        for (Object value : values) {
488
                            Element element = (Element) value;
Matt Tucker's avatar
Matt Tucker committed
489 490
                            // Make sure there's a name or description. Otherwise, no need to
                            // override i18n settings.
491 492
                            if (element.attribute("name") != null ||
                                    element.attribute("value") != null) {
Matt Tucker's avatar
Matt Tucker committed
493 494 495 496 497
                                element.addAttribute("plugin", pluginName);
                            }
                        }
                    }

498 499
                    AdminConsole.addModel(pluginName, adminElement);
                }
500
                firePluginCreatedEvent(pluginName, plugin);
501 502 503 504 505
            }
            else {
                Log.warn("Plugin " + pluginDir + " could not be loaded: no plugin.xml file found");
            }
        }
506
        catch (Throwable e) {
507
            Log.error("Error loading plugin: " + pluginDir, e);
508 509 510
        }
    }

511 512 513 514 515 516 517 518 519 520 521 522 523 524
    private void configureCaches(File pluginDir, String pluginName) {
        File cacheConfig = new File(pluginDir, "cache-config.xml");
        if (cacheConfig.exists()) {
            PluginCacheConfigurator configurator = new PluginCacheConfigurator();
            try {
                configurator.setInputStream(new BufferedInputStream(new FileInputStream(cacheConfig)));
                configurator.configure(pluginName);
            }
            catch (Exception e) {
                Log.error(e);
            }
        }
    }

Alex Wenckus's avatar
Alex Wenckus committed
525 526 527 528 529 530
    private void firePluginCreatedEvent(String name, Plugin plugin) {
        for(PluginListener listener : pluginListeners) {
            listener.pluginCreated(name, plugin);
        }
    }

531 532 533 534 535 536
    private void firePluginsMonitored() {
        for(PluginManagerListener listener : pluginManagerListeners) {
            listener.pluginsMonitored();
        }
    }

537 538 539 540 541 542 543 544 545 546 547 548 549
    /**
     * Unloads a plugin. The {@link Plugin#destroyPlugin()} method will be called and then
     * any resources will be released. The name should be the name of the plugin directory
     * and not the name as given by the plugin meta-data. This method only removes
     * the plugin but does not delete the plugin JAR file. Therefore, if the plugin JAR
     * still exists after this method is called, the plugin will be started again the next
     * time the plugin monitor process runs. This is useful for "restarting" plugins.<p>
     * <p/>
     * This method is called automatically when a plugin's JAR file is deleted.
     *
     * @param pluginName the name of the plugin to unload.
     */
    public void unloadPlugin(String pluginName) {
550
        Log.debug("PluginManager: Unloading plugin " + pluginName);
551 552

        Plugin plugin = plugins.get(pluginName);
553 554 555 556 557 558 559
        if (plugin != null) {
            // Remove from dev mode if it exists.
            pluginDevelopment.remove(plugin);

            // See if any child plugins are defined.
            if (parentPluginMap.containsKey(plugin)) {
                for (String childPlugin : parentPluginMap.get(plugin)) {
560
                    Log.debug("PluginManager: Unloading child plugin: " + childPlugin);
561 562 563 564
                    unloadPlugin(childPlugin);
                }
                parentPluginMap.remove(plugin);
            }
565

566 567 568 569 570 571 572 573 574 575
            File webXML = new File(pluginDirectory, pluginName + File.separator + "web" + File.separator + "WEB-INF" +
                File.separator + "web.xml");
            if (webXML.exists()) {
                AdminConsole.removeModel(pluginName);
                PluginServlet.unregisterServlets(webXML);
            }
            File customWebXML = new File(pluginDirectory, pluginName + File.separator + "web" + File.separator + "WEB-INF" +
                File.separator + "web-custom.xml");
            if (customWebXML.exists()) {
                PluginServlet.unregisterServlets(customWebXML);
576 577
            }

578 579 580 581 582 583 584 585 586 587 588
            // Wrap destroying the plugin in a try/catch block. Otherwise, an exception raised
            // in the destroy plugin process will disrupt the whole unloading process. It's still
            // possible that classloader destruction won't work in the case that destroying the plugin
            // fails. In that case, Openfire may need to be restarted to fully cleanup the plugin
            // resources.
            try {
                plugin.destroyPlugin();
            }
            catch (Exception e) {
                Log.error(e);
            }
589 590
        }

591 592 593 594 595 596 597 598
        // Remove references to the plugin so it can be unloaded from memory
        // If plugin still fails to be removed then we will add references back
        // Anyway, for a few seconds admins may not see the plugin in the admin console
        // and in a subsequent refresh it will appear if failed to be removed
        plugins.remove(pluginName);
        File pluginFile = pluginDirs.remove(plugin);
        PluginClassLoader pluginLoader = classloaders.remove(plugin);

599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619
        // Try to remove the folder where the plugin was exploded. If this works then
        // the plugin was successfully removed. Otherwise, some objects created by the
        // plugin are still in memory.
        File dir = new File(pluginDirectory, pluginName);
        // Give the plugin 2 seconds to unload.
        try {
            Thread.sleep(2000);
            // Ask the system to clean up references.
            System.gc();
            int count = 0;
            while (!deleteDir(dir) && count < 5) {
                Log.warn("Error unloading plugin " + pluginName + ". " + "Will attempt again momentarily.");
                Thread.sleep(8000);
                count++;
                // Ask the system to clean up references.
                System.gc();
            }
        } catch (InterruptedException e) {
            Log.error(e);
        }

620
        if (plugin != null && !dir.exists()) {
621
            // Unregister plugin caches
622
            PluginCacheRegistry.getInstance().unregisterCaches(pluginName);
623

624 625 626 627 628 629 630
            // See if this is a child plugin. If it is, we should unload
            // the parent plugin as well.
            if (childPluginMap.containsKey(plugin)) {
                unloadPlugin(childPluginMap.get(plugin));
            }
            childPluginMap.remove(plugin);
            firePluginDestroyedEvent(pluginName, plugin);
631
        }
632
        else if (plugin != null) {
633 634 635 636 637
            // Restore references since we failed to remove the plugin
            plugins.put(pluginName, plugin);
            pluginDirs.put(plugin, pluginFile);
            classloaders.put(plugin, pluginLoader);
        }
Alex Wenckus's avatar
Alex Wenckus committed
638 639 640 641 642 643
    }

    private void firePluginDestroyedEvent(String name, Plugin plugin) {
        for (PluginListener listener : pluginListeners) {
            listener.pluginDestroyed(name, plugin);
        }
644 645 646 647 648
    }

    /**
     * Loads a class from the classloader of a plugin.
     *
Matt Tucker's avatar
Matt Tucker committed
649
     * @param plugin the plugin.
650 651 652 653 654 655 656
     * @param className the name of the class to load.
     * @return the class.
     * @throws ClassNotFoundException if the class was not found.
     * @throws IllegalAccessException if not allowed to access the class.
     * @throws InstantiationException if the class could not be created.
     */
    public Class loadClass(Plugin plugin, String className) throws ClassNotFoundException,
657
        IllegalAccessException, InstantiationException {
658 659 660 661 662 663 664 665 666 667
        PluginClassLoader loader = classloaders.get(plugin);
        return loader.loadClass(className);
    }

    /**
     * Returns a plugin's dev environment if development mode is enabled for
     * the plugin.
     *
     * @param plugin the plugin.
     * @return the plugin dev environment, or <tt>null</tt> if development
668
     *         mode is not enabled for the plugin.
669 670 671 672 673 674 675 676 677 678 679 680 681 682
     */
    public PluginDevEnvironment getDevEnvironment(Plugin plugin) {
        return pluginDevelopment.get(plugin);
    }

    /**
     * Returns the name of a plugin. The value is retrieved from the plugin.xml file
     * of the plugin. If the value could not be found, <tt>null</tt> will be returned.
     * Note that this value is distinct from the name of the plugin directory.
     *
     * @param plugin the plugin.
     * @return the plugin's name.
     */
    public String getName(Plugin plugin) {
683 684
        String name = getElementValue(plugin, "/plugin/name");
        String pluginName = pluginDirs.get(plugin).getName();
685
        if (name != null) {
Matt Tucker's avatar
Matt Tucker committed
686
            return AdminConsole.getAdminText(name, pluginName);
687 688
        }
        else {
Matt Tucker's avatar
Matt Tucker committed
689
            return pluginName;
690 691 692 693 694 695 696 697 698 699 700
        }
    }

    /**
     * Returns the description of a plugin. The value is retrieved from the plugin.xml file
     * of the plugin. If the value could not be found, <tt>null</tt> will be returned.
     *
     * @param plugin the plugin.
     * @return the plugin's description.
     */
    public String getDescription(Plugin plugin) {
701 702
        String pluginName = pluginDirs.get(plugin).getName();
        return AdminConsole.getAdminText(getElementValue(plugin, "/plugin/description"), pluginName);
703 704 705 706 707 708 709 710 711 712
    }

    /**
     * Returns the author of a plugin. The value is retrieved from the plugin.xml file
     * of the plugin. If the value could not be found, <tt>null</tt> will be returned.
     *
     * @param plugin the plugin.
     * @return the plugin's author.
     */
    public String getAuthor(Plugin plugin) {
713
        return getElementValue(plugin, "/plugin/author");
714 715 716 717 718 719 720 721 722 723
    }

    /**
     * Returns the version of a plugin. The value is retrieved from the plugin.xml file
     * of the plugin. If the value could not be found, <tt>null</tt> will be returned.
     *
     * @param plugin the plugin.
     * @return the plugin's version.
     */
    public String getVersion(Plugin plugin) {
724
        return getElementValue(plugin, "/plugin/version");
725 726
    }

727 728 729 730 731 732 733 734
     /**
     * Returns the minimum server version this plugin can run within. The value is retrieved from the plugin.xml file
     * of the plugin. If the value could not be found, <tt>null</tt> will be returned.
     *
     * @param plugin the plugin.
     * @return the plugin's version.
     */
    public String getMinServerVersion(Plugin plugin) {
735
        return getElementValue(plugin, "/plugin/minServerVersion");
736 737
    }

738 739 740 741 742 743 744 745 746
    /**
     * Returns the database schema key of a plugin, if it exists. The value is retrieved
     * from the plugin.xml file of the plugin. If the value could not be found, <tt>null</tt>
     * will be returned.
     *
     * @param plugin the plugin.
     * @return the plugin's database schema key or <tt>null</tt> if it doesn't exist.
     */
    public String getDatabaseKey(Plugin plugin) {
747
        return getElementValue(plugin, "/plugin/databaseKey");
748 749 750 751 752 753 754 755 756 757 758
    }

    /**
     * Returns the database schema version of a plugin, if it exists. The value is retrieved
     * from the plugin.xml file of the plugin. If the value could not be found, <tt>-1</tt>
     * will be returned.
     *
     * @param plugin the plugin.
     * @return the plugin's database schema version or <tt>-1</tt> if it doesn't exist.
     */
    public int getDatabaseVersion(Plugin plugin) {
759
        String versionString = getElementValue(plugin, "/plugin/databaseVersion");
760 761 762 763 764 765 766 767 768 769 770
        if (versionString != null) {
            try {
                return Integer.parseInt(versionString.trim());
            }
            catch (NumberFormatException nfe) {
                Log.error(nfe);
            }
        }
        return -1;
    }

771 772 773 774 775 776 777 778 779
    /**
     * Returns the license agreement type that the plugin is governed by. The value
     * is retrieved from the plugin.xml file of the plugin. If the value could not be
     * found, {@link License#other} is returned.
     *
     * @param plugin the plugin.
     * @return the plugin's license agreement.
     */
    public License getLicense(Plugin plugin) {
780
        String licenseString = getElementValue(plugin, "/plugin/licenseType");
781 782 783 784 785 786 787 788 789 790 791 792 793 794 795
        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(iae);
            }
        }
        return License.other;
    }

796 797
    /**
     * Returns the classloader of a plugin.
798
     *
799 800 801
     * @param plugin the plugin.
     * @return the classloader of the plugin.
     */
802
    public PluginClassLoader getPluginClassloader(Plugin plugin) {
803 804 805 806 807 808 809
        return classloaders.get(plugin);
    }

    /**
     * Returns the value of an element selected via an xpath expression from
     * a Plugin's plugin.xml file.
     *
810
     * @param plugin the plugin.
811 812 813
     * @param xpath  the xpath expression.
     * @return the value of the element selected by the xpath expression.
     */
814 815
    private String getElementValue(Plugin plugin, String xpath) {
        File pluginDir = pluginDirs.get(plugin);
816 817 818 819 820 821 822
        if (pluginDir == null) {
            return null;
        }
        try {
            File pluginConfig = new File(pluginDir, "plugin.xml");
            if (pluginConfig.exists()) {
                SAXReader saxReader = new SAXReader();
823
                saxReader.setEncoding("UTF-8");
824 825 826 827 828 829 830 831 832 833 834 835 836
                Document pluginXML = saxReader.read(pluginConfig);
                Element element = (Element)pluginXML.selectSingleNode(xpath);
                if (element != null) {
                    return element.getTextTrim();
                }
            }
        }
        catch (Exception e) {
            Log.error(e);
        }
        return null;
    }

837 838 839
    /**
     * An enumberation for plugin license agreement types.
     */
840
    @SuppressWarnings({"UnnecessarySemicolon"})  // Support for QDox Parser
841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867
    public enum License {

        /**
         * The plugin is distributed using a commercial license.
         */
        commercial,

        /**
         * The plugin is distributed using the GNU Public License (GPL).
         */
        gpl,

        /**
         * The plugin is distributed using the Apache license.
         */
        apache,

        /**
         * The plugin is for internal use at an organization only and is not re-distributed.
         */
        internal,

        /**
         * The plugin is distributed under another license agreement not covered by
         * one of the other choices. The license agreement should be detailed in the
         * plugin Readme.
         */
868
        other;
869 870
    }

871 872 873 874 875 876 877
    /**
     * A service that monitors the plugin directory for plugins. It periodically
     * checks for new plugin JAR files and extracts them if they haven't already
     * been extracted. Then, any new plugin directories are loaded.
     */
    private class PluginMonitor implements Runnable {

878 879 880 881 882
        /**
         * Tracks if the monitor is currently running.
         */
        private boolean running = false;

883 884 885 886 887 888
        /**
         * True if the monitor has been executed at least once. After the first iteration in {@link #run}
         * this variable will always be true.
         * */
        private boolean executed = false;

889 890 891 892 893 894
        /**
         * True when it's the first time the plugin monitor process runs. This is helpful for
         * bootstrapping purposes.
         */
        private boolean firstRun = true;

895
        public void run() {
896 897 898 899 900 901 902
            // If the task is already running, return.
            synchronized (this) {
                if (running) {
                    return;
                }
                running = true;
            }
903
            try {
904
                running = true;
905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928
                // Look for extra plugin directories specified as a system property.
                String pluginDirs = System.getProperty("pluginDirs");
                if (pluginDirs != null) {
                    StringTokenizer st = new StringTokenizer(pluginDirs, ", ");
                    while (st.hasMoreTokens()) {
                        String dir = st.nextToken();
                        if (!devPlugins.contains(dir)) {
                            loadPlugin(new File(dir));
                            devPlugins.add(dir);
                        }
                    }
                }

                File[] jars = pluginDirectory.listFiles(new FileFilter() {
                    public boolean accept(File pathname) {
                        String fileName = pathname.getName().toLowerCase();
                        return (fileName.endsWith(".jar") || fileName.endsWith(".war"));
                    }
                });

                if (jars == null) {
                    return;
                }

Matt Tucker's avatar
Matt Tucker committed
929
                for (File jarFile : jars) {
930
                    String pluginName = jarFile.getName().substring(0,
931
                        jarFile.getName().length() - 4).toLowerCase();
932 933
                    // See if the JAR has already been exploded.
                    File dir = new File(pluginDirectory, pluginName);
934 935
                    // Store the JAR/WAR file that created the plugin folder
                    pluginFiles.put(pluginName, jarFile);
936 937 938 939 940 941 942
                    // If the JAR hasn't been exploded, do so.
                    if (!dir.exists()) {
                        unzipPlugin(pluginName, jarFile, dir);
                    }
                    // See if the JAR is newer than the directory. If so, the plugin
                    // needs to be unloaded and then reloaded.
                    else if (jarFile.lastModified() > dir.lastModified()) {
943 944 945 946 947 948 949 950 951 952 953 954
                        // If this is the first time that the monitor process is running, then
                        // plugins won't be loaded yet. Therefore, just delete the directory.
                        if (firstRun) {
                            int count = 0;
                            // Attempt to delete the folder for up to 5 seconds.
                            while (!deleteDir(dir) && count < 5) {
                                Thread.sleep(1000);
                            }
                        }
                        else {
                            unloadPlugin(pluginName);
                        }
955
                        // If the delete operation was a success, unzip the plugin.
956
                        if (!dir.exists()) {
957 958
                            unzipPlugin(pluginName, jarFile, dir);
                        }
959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977
                    }
                }

                File[] dirs = pluginDirectory.listFiles(new FileFilter() {
                    public boolean accept(File pathname) {
                        return pathname.isDirectory();
                    }
                });

                // Sort the list of directories so that the "admin" plugin is always
                // first in the list.
                Arrays.sort(dirs, new Comparator<File>() {
                    public int compare(File file1, File file2) {
                        if (file1.getName().equals("admin")) {
                            return -1;
                        }
                        else if (file2.getName().equals("admin")) {
                            return 1;
                        }
978
                        else {
979
                            return file1.compareTo(file2);
980
                        }
981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010
                    }
                });

                // Turn the list of JAR/WAR files into a set so that we can do lookups.
                Set<String> jarSet = new HashSet<String>();
                for (File file : jars) {
                    jarSet.add(file.getName().toLowerCase());
                }

                // See if any currently running plugins need to be unloaded
                // due to the JAR file being deleted (ignore admin plugin).
                // Build a list of plugins to delete first so that the plugins
                // keyset isn't modified as we're iterating through it.
                List<String> toDelete = new ArrayList<String>();
                for (File pluginDir : dirs) {
                    String pluginName = pluginDir.getName();
                    if (pluginName.equals("admin")) {
                        continue;
                    }
                    if (!jarSet.contains(pluginName + ".jar")) {
                        if (!jarSet.contains(pluginName + ".war")) {
                            toDelete.add(pluginName);
                        }
                    }
                }
                for (String pluginName : toDelete) {
                    unloadPlugin(pluginName);
                }

                // Load all plugins that need to be loaded.
Matt Tucker's avatar
Matt Tucker committed
1011
                for (File dirFile : dirs) {
1012 1013 1014 1015 1016
                    // If the plugin hasn't already been started, start it.
                    if (dirFile.exists() && !plugins.containsKey(dirFile.getName())) {
                        loadPlugin(dirFile);
                    }
                }
1017 1018 1019 1020 1021 1022 1023 1024
                // 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();
1025
            }
1026
            catch (Throwable e) {
1027 1028
                Log.error(e);
            }
1029 1030 1031 1032
            // Finished running task.
            synchronized (this) {
                running = false;
            }
1033 1034
            // Process finished, so set firstRun to false (setting it multiple times doesn't hurt).
            firstRun = false;
1035 1036 1037 1038 1039 1040 1041
        }

        /**
         * 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.
Matt Tucker's avatar
Matt Tucker committed
1042 1043
         * @param file the JAR file
         * @param dir the directory to extract the plugin to.
1044 1045 1046 1047 1048 1049 1050 1051 1052
         */
        private void unzipPlugin(String pluginName, File file, File dir) {
            try {
                ZipFile zipFile = new JarFile(file);
                // Ensure that this JAR is a plugin.
                if (zipFile.getEntry("plugin.xml") == null) {
                    return;
                }
                dir.mkdir();
1053 1054
                // Set the date of the JAR file to the newly created folder
                dir.setLastModified(file.lastModified());
1055
                Log.debug("PluginManager: Extracting plugin: " + pluginName);
1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067
                for (Enumeration e = zipFile.entries(); e.hasMoreElements();) {
                    JarEntry entry = (JarEntry)e.nextElement();
                    File entryFile = new File(dir, entry.getName());
                    // Ignore any manifest.mf entries.
                    if (entry.getName().toLowerCase().endsWith("manifest.mf")) {
                        continue;
                    }
                    if (!entry.isDirectory()) {
                        entryFile.getParentFile().mkdirs();
                        FileOutputStream out = new FileOutputStream(entryFile);
                        InputStream zin = zipFile.getInputStream(entry);
                        byte[] b = new byte[512];
Matt Tucker's avatar
Matt Tucker committed
1068
                        int len;
1069 1070 1071 1072 1073 1074 1075 1076 1077
                        while ((len = zin.read(b)) != -1) {
                            out.write(b, 0, len);
                        }
                        out.flush();
                        out.close();
                        zin.close();
                    }
                }
                zipFile.close();
1078 1079 1080 1081

                // The lib directory of the plugin may contain Pack200 versions of the JAR
                // file. If so, unpack them.
                unpackArchives(new File(dir, "lib"));
1082 1083 1084 1085 1086 1087
            }
            catch (Exception e) {
                Log.error(e);
            }
        }

1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113
        /**
         * Converts any pack files in a directory into standard JAR files. Each
         * pack file will be deleted after being converted to a JAR. If no
         * pack files are found, this method does nothing.
         *
         * @param libDir the directory containing pack files.
         */
        private void unpackArchives(File libDir) {
            // Get a list of all packed files in the lib directory.
            File [] packedFiles = libDir.listFiles(new FilenameFilter() {
                public boolean accept(File dir, String name) {
                    return name.endsWith(".pack");
                }
            });

            if (packedFiles == null) {
                // Do nothing since no .pack files were found
                return;
            }

            // Unpack each.
            for (File packedFile : packedFiles) {
                try {
                    String jarName = packedFile.getName().substring(0,
                            packedFile.getName().length() - ".pack".length());
                    // Delete JAR file with same name if it exists (could be due to upgrade
1114
                    // from old Openfire release).
1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136
                    File jarFile = new File(libDir, jarName);
                    if (jarFile.exists()) {
                        jarFile.delete();
                    }

                    InputStream in = new BufferedInputStream(new FileInputStream(packedFile));
                    JarOutputStream out = new JarOutputStream(new BufferedOutputStream(
                            new FileOutputStream(new File(libDir, jarName))));
                    Pack200.Unpacker unpacker = Pack200.newUnpacker();
                    // Call the unpacker
                    unpacker.unpack(in, out);

                    in.close();
                    out.close();
                    packedFile.delete();
                }
                catch (Exception e) {
                    Log.error(e);
                }
            }
        }

1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155
    }

    /**
     * Deletes a directory.
     *
     * @param dir the directory to delete.
     * @return true if the directory was deleted.
     */
    private boolean deleteDir(File dir) {
        if (dir.isDirectory()) {
            String[] childDirs = dir.list();
            // Always try to delete JAR files first since that's what will
            // be under contention. We do this by always sorting the lib directory
            // first.
            List<String> children = new ArrayList<String>(Arrays.asList(childDirs));
            Collections.sort(children, new Comparator<String>() {
                public int compare(String o1, String o2) {
                    if (o1.equals("lib")) {
                        return -1;
1156
                    }
1157 1158 1159 1160 1161
                    if (o2.equals("lib")) {
                        return 1;
                    }
                    else {
                        return o1.compareTo(o2);
1162 1163
                    }
                }
1164 1165 1166 1167
            });
            for (String file : children) {
                boolean success = deleteDir(new File(dir, file));
                if (!success) {
1168
                    Log.debug("PluginManager: Plugin removal: could not delete: " + new File(dir, file));
1169 1170
                    return false;
                }
1171 1172
            }
        }
1173
        boolean deleted = !dir.exists() || dir.delete();
1174 1175 1176 1177 1178
        if (deleted) {
            // Remove the JAR/WAR file that created the plugin folder
            pluginFiles.remove(dir.getName());
        }
        return deleted;
1179
    }
Alex Wenckus's avatar
Alex Wenckus committed
1180 1181 1182 1183 1184 1185 1186 1187

    public void addPluginListener(PluginListener listener) {
        pluginListeners.add(listener);
    }

    public void removePluginListener(PluginListener listener) {
        pluginListeners.remove(listener);
    }
1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198

    public void addPluginManagerListener(PluginManagerListener listener) {
        pluginManagerListeners.add(listener);
        if (isExecuted()) {
            firePluginsMonitored();
        }
    }

    public void removePluginManagerListener(PluginManagerListener listener) {
        pluginManagerListeners.remove(listener);
    }
1199
}