Support serving static/ and Documentation/ from plugins

The static/ and Documentation/ resource directories of a plugin can be
served over HTTP for any loaded and running plugin, even if it has no
other HTTP handlers. This permits a plugin to supply icons or other
graphics for the web UI, or documentation content to help users learn
how to use the plugin.

Change-Id: I267176cc76e161617d780438f88531fc50c1c2b8
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index c3f78e6..86f886c 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -17,19 +17,29 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
+import com.google.gerrit.server.MimeUtilFileTypeRegistry;
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.RegistrationHandle;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.plugins.StartPluginListener;
+import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.servlet.GuiceFilter;
 
+import eu.medsea.mimeutil.MimeType;
+
+import org.eclipse.jgit.util.IO;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
 import java.util.List;
 import java.util.concurrent.ConcurrentMap;
+import java.util.jar.Attributes;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
 
 import javax.servlet.FilterChain;
 import javax.servlet.ServletConfig;
@@ -48,11 +58,17 @@
   private static final Logger log
       = LoggerFactory.getLogger(HttpPluginServlet.class);
 
+  private final MimeUtilFileTypeRegistry mimeUtil;
   private List<Plugin> pending = Lists.newArrayList();
   private String base;
-  private final ConcurrentMap<String, GuiceFilter> plugins
+  private final ConcurrentMap<String, PluginHolder> plugins
       = Maps.newConcurrentMap();
 
+  @Inject
+  HttpPluginServlet(MimeUtilFileTypeRegistry mimeUtil) {
+    this.mimeUtil = mimeUtil;
+  }
+
   @Override
   public synchronized void init(ServletConfig config) throws ServletException {
     super.init(config);
@@ -60,10 +76,7 @@
     String path = config.getServletContext().getContextPath();
     base = Strings.nullToEmpty(path) + "/plugins/";
     for (Plugin plugin : pending) {
-      GuiceFilter filter = load(plugin);
-      if (filter != null) {
-        plugins.put(plugin.getName(), filter);
-      }
+      install(plugin);
     }
     pending = null;
   }
@@ -73,19 +86,26 @@
     if (pending != null) {
       pending.add(plugin);
     } else {
-      GuiceFilter filter = load(plugin);
-      if (filter != null) {
-        plugins.put(plugin.getName(), filter);
-      }
+      install(plugin);
     }
   }
 
   @Override
   public void onReloadPlugin(Plugin oldPlugin, Plugin newPlugin) {
-    GuiceFilter filter = load(newPlugin);
-    if (filter != null) {
-      plugins.put(newPlugin.getName(), filter);
-    }
+    install(newPlugin);
+  }
+
+  private void install(Plugin plugin) {
+    GuiceFilter filter = load(plugin);
+    final String name = plugin.getName();
+    final PluginHolder holder = new PluginHolder(plugin, filter);
+    plugin.add(new RegistrationHandle() {
+      @Override
+      public void remove() {
+        plugins.remove(name, holder);
+      }
+    });
+    plugins.put(name, holder);
   }
 
   private GuiceFilter load(Plugin plugin) {
@@ -110,11 +130,7 @@
       plugin.add(new RegistrationHandle() {
         @Override
         public void remove() {
-          try {
-            filter.destroy();
-          } finally {
-            plugins.remove(name, filter);
-          }
+          filter.destroy();
         }
       });
       return filter;
@@ -126,23 +142,95 @@
   public void service(HttpServletRequest req, HttpServletResponse res)
       throws IOException, ServletException {
     String name = extractName(req);
-    GuiceFilter filter = plugins.get(name);
-    if (filter == null) {
+    final PluginHolder holder = plugins.get(name);
+    if (holder == null) {
       noCache(res);
       res.sendError(HttpServletResponse.SC_NOT_FOUND);
       return;
     }
 
-    filter.doFilter(new WrappedRequest(req, base + name), res,
-        new FilterChain() {
-          @Override
-          public void doFilter(ServletRequest req, ServletResponse response)
-              throws IOException, ServletException {
-            HttpServletResponse res = (HttpServletResponse) response;
-            noCache(res);
-            res.sendError(HttpServletResponse.SC_NOT_FOUND);
+    WrappedRequest wr = new WrappedRequest(req, base + name);
+    FilterChain chain = new FilterChain() {
+      @Override
+      public void doFilter(ServletRequest req, ServletResponse res)
+          throws IOException {
+        onDefault(holder, (HttpServletRequest) req, (HttpServletResponse) res);
+      }
+    };
+    if (holder.filter != null) {
+      holder.filter.doFilter(wr, res, chain);
+    } else {
+      chain.doFilter(wr, res);
+    }
+  }
+
+  private void onDefault(PluginHolder holder,
+      HttpServletRequest req,
+      HttpServletResponse res) throws IOException {
+    String uri = req.getRequestURI();
+    String ctx = req.getContextPath();
+    String file = uri.substring(ctx.length() + 1);
+    if (file.startsWith("Documentation/") || file.startsWith("static/")) {
+      JarFile jar = holder.plugin.getJarFile();
+      JarEntry entry = jar.getJarEntry(file);
+      if (entry != null && entry.getSize() > 0) {
+        sendResource(jar, entry, res);
+        return;
+      }
+    }
+
+    noCache(res);
+    res.sendError(HttpServletResponse.SC_NOT_FOUND);
+  }
+
+  private void sendResource(JarFile jar, JarEntry entry, HttpServletResponse res)
+      throws IOException {
+    byte[] data = null;
+    if (entry.getSize() <= 128 * 1024) {
+      data = new byte[(int) entry.getSize()];
+      InputStream in = jar.getInputStream(entry);
+      try {
+        IO.readFully(in, data, 0, data.length);
+      } finally {
+        in.close();
+      }
+    }
+
+    String contentType = null;
+    Attributes atts = entry.getAttributes();
+    if (atts != null) {
+      contentType = Strings.emptyToNull(atts.getValue("Content-Type"));
+    }
+    if (contentType == null) {
+      MimeType type = mimeUtil.getMimeType(entry.getName(), data);
+      contentType = type.toString();
+    }
+
+    long time = entry.getTime();
+    if (0 < time) {
+      res.setDateHeader("Last-Modified", time);
+    }
+    res.setContentType(contentType);
+    res.setHeader("Content-Length", Long.toString(entry.getSize()));
+    if (data != null) {
+      res.getOutputStream().write(data);
+    } else {
+      InputStream in = jar.getInputStream(entry);
+      try {
+        OutputStream out = res.getOutputStream();
+        try {
+          byte[] tmp = new byte[1024];
+          int n;
+          while ((n = in.read(tmp)) > 0) {
+            out.write(tmp, 0, n);
           }
-        });
+        } finally {
+          out.close();
+        }
+      } finally {
+        in.close();
+      }
+    }
   }
 
   private static String extractName(HttpServletRequest req) {
@@ -161,6 +249,16 @@
     res.setHeader("Content-Disposition", "attachment");
   }
 
+  private static class PluginHolder {
+    final Plugin plugin;
+    final GuiceFilter filter;
+
+    PluginHolder(Plugin plugin, GuiceFilter filter) {
+      this.plugin = plugin;
+      this.filter = filter;
+    }
+  }
+
   private static class WrappedRequest extends HttpServletRequestWrapper {
     private final String contextPath;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/CleanupHandle.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/CleanupHandle.java
index 418dee9..e18d840 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/CleanupHandle.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/CleanupHandle.java
@@ -15,20 +15,31 @@
 package com.google.gerrit.server.plugins;
 
 import java.io.File;
+import java.io.IOException;
 import java.lang.ref.ReferenceQueue;
 import java.lang.ref.WeakReference;
+import java.util.jar.JarFile;
 
 class CleanupHandle extends WeakReference<ClassLoader> {
   private final File tmpFile;
+  private final JarFile jarFile;
 
-  CleanupHandle(File jarFile,
+  CleanupHandle(File tmpFile,
+      JarFile jarFile,
       ClassLoader ref,
       ReferenceQueue<ClassLoader> queue) {
     super(ref, queue);
-    this.tmpFile = jarFile;
+    this.tmpFile = tmpFile;
+    this.jarFile = jarFile;
   }
 
   void cleanup() {
-    tmpFile.delete();
+    try {
+      jarFile.close();
+    } catch (IOException err) {
+    }
+    if (!tmpFile.delete() && tmpFile.exists()) {
+      PluginLoader.log.warn("Cannot delete " + tmpFile.getAbsolutePath());
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
index 497b0f5..9e8da32 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/Plugin.java
@@ -27,6 +27,7 @@
 
 import java.io.File;
 import java.util.jar.Attributes;
+import java.util.jar.JarFile;
 import java.util.jar.Manifest;
 
 import javax.annotation.Nullable;
@@ -40,9 +41,10 @@
   }
 
   private final String name;
-  private final File jar;
-  private final Manifest manifest;
+  private final File srcJar;
   private final FileSnapshot snapshot;
+  private final JarFile jarFile;
+  private final Manifest manifest;
   private Class<? extends Module> sysModule;
   private Class<? extends Module> sshModule;
   private Class<? extends Module> httpModule;
@@ -53,23 +55,25 @@
   private LifecycleManager manager;
 
   public Plugin(String name,
-      File jar,
-      Manifest manifest,
+      File srcJar,
       FileSnapshot snapshot,
+      JarFile jarFile,
+      Manifest manifest,
       @Nullable Class<? extends Module> sysModule,
       @Nullable Class<? extends Module> sshModule,
       @Nullable Class<? extends Module> httpModule) {
     this.name = name;
-    this.jar = jar;
-    this.manifest = manifest;
+    this.srcJar = srcJar;
     this.snapshot = snapshot;
+    this.jarFile = jarFile;
+    this.manifest = manifest;
     this.sysModule = sysModule;
     this.sshModule = sshModule;
     this.httpModule = httpModule;
   }
 
-  File getJar() {
-    return jar;
+  File getSrcJar() {
+    return srcJar;
   }
 
   public String getName() {
@@ -151,6 +155,10 @@
     }
   }
 
+  public JarFile getJarFile() {
+    return jarFile;
+  }
+
   @Nullable
   public Injector getSshInjector() {
     return sshInjector;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
index b0b0667..330dc46 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -169,7 +169,7 @@
 
         log.info(String.format("Disabling plugin %s", name));
         File off = new File(pluginsDir, active.getName() + ".jar.disabled");
-        active.getJar().renameTo(off);
+        active.getSrcJar().renameTo(off);
 
         active.stop();
         running.remove(name);
@@ -304,34 +304,45 @@
     return 0 < ext ? name.substring(0, ext) : name;
   }
 
-  private Plugin loadPlugin(String name, File jarFile, FileSnapshot snapshot)
+  private Plugin loadPlugin(String name, File srcJar, FileSnapshot snapshot)
       throws IOException, ClassNotFoundException {
     File tmp;
-    FileInputStream in = new FileInputStream(jarFile);
+    FileInputStream in = new FileInputStream(srcJar);
     try {
       tmp = asTemp(in, tempNameFor(name), ".jar", tmpDir);
     } finally {
       in.close();
     }
 
-    Manifest manifest = new JarFile(tmp).getManifest();
-    Attributes main = manifest.getMainAttributes();
-    String sysName = main.getValue("Gerrit-Module");
-    String sshName = main.getValue("Gerrit-SshModule");
-    String httpName = main.getValue("Gerrit-HttpModule");
+    JarFile jarFile = new JarFile(tmp);
+    boolean keep = false;
+    try {
+      Manifest manifest = jarFile.getManifest();
+      Attributes main = manifest.getMainAttributes();
+      String sysName = main.getValue("Gerrit-Module");
+      String sshName = main.getValue("Gerrit-SshModule");
+      String httpName = main.getValue("Gerrit-HttpModule");
 
-    URL[] urls = {tmp.toURI().toURL()};
-    ClassLoader parentLoader = PluginLoader.class.getClassLoader();
-    ClassLoader pluginLoader = new URLClassLoader(urls, parentLoader);
-    cleanupHandles.put(
-        new CleanupHandle(tmp, pluginLoader, cleanupQueue),
-        Boolean.TRUE);
+      URL[] urls = {tmp.toURI().toURL()};
+      ClassLoader parentLoader = PluginLoader.class.getClassLoader();
+      ClassLoader pluginLoader = new URLClassLoader(urls, parentLoader);
+      cleanupHandles.put(
+          new CleanupHandle(tmp, jarFile, pluginLoader, cleanupQueue),
+          Boolean.TRUE);
 
-    Class<? extends Module> sysModule = load(sysName, pluginLoader);
-    Class<? extends Module> sshModule = load(sshName, pluginLoader);
-    Class<? extends Module> httpModule = load(httpName, pluginLoader);
-    return new Plugin(name, jarFile, manifest, snapshot,
-        sysModule, sshModule, httpModule);
+      Class<? extends Module> sysModule = load(sysName, pluginLoader);
+      Class<? extends Module> sshModule = load(sshName, pluginLoader);
+      Class<? extends Module> httpModule = load(httpName, pluginLoader);
+      keep = true;
+      return new Plugin(name,
+          srcJar, snapshot,
+          jarFile, manifest,
+          sysModule, sshModule, httpModule);
+    } finally {
+      if (!keep) {
+        jarFile.close();
+      }
+    }
   }
 
   private static String tempNameFor(String name) {