Serve static resources for non-jar Server plugins

Abstract from the Server plugins external packaging
and allows to serve static resources from any
form of Server plugin that exposes a PluginContentScanner.

This allows potentially other forms of plugins
(e.g. Scripting, directory-based or any other) to
provide their on-line documentation and serve their
static resources.

Change-Id: I80b8159cf87255e3132298f5863b374b5bd44a6c
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 2b40716..f532770 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
@@ -14,7 +14,11 @@
 
 package com.google.gerrit.httpd.plugins;
 
+import static com.google.gerrit.server.plugins.PluginEntry.ATTR_CHARACTER_ENCODING;
+import static com.google.gerrit.server.plugins.PluginEntry.ATTR_CONTENT_TYPE;
+
 import com.google.common.base.CharMatcher;
+import com.google.common.base.Optional;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.cache.Cache;
@@ -27,9 +31,11 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.documentation.MarkdownFormatter;
 import com.google.gerrit.server.plugins.Plugin;
+import com.google.gerrit.server.plugins.Plugin.ApiType;
+import com.google.gerrit.server.plugins.PluginContentScanner;
+import com.google.gerrit.server.plugins.PluginEntry;
 import com.google.gerrit.server.plugins.PluginsCollection;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
-import com.google.gerrit.server.plugins.ServerPlugin;
 import com.google.gerrit.server.plugins.StartPluginListener;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gwtexpui.server.CacheHeaders;
@@ -55,14 +61,11 @@
 import java.io.UnsupportedEncodingException;
 import java.nio.charset.Charset;
 import java.util.Collections;
-import java.util.Comparator;
 import java.util.Enumeration;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentMap;
 import java.util.jar.Attributes;
-import java.util.jar.JarEntry;
-import java.util.jar.JarFile;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -83,13 +86,6 @@
   private static final long serialVersionUID = 1L;
   private static final Logger log
       = LoggerFactory.getLogger(HttpPluginServlet.class);
-  private static final Comparator<JarEntry> JAR_ENTRY_COMPARATOR_BY_NAME =
-      new Comparator<JarEntry>() {
-        @Override
-        public int compare(JarEntry a, JarEntry b) {
-          return a.getName().compareTo(b.getName());
-        }
-      };
 
   private final MimeUtilFileTypeRegistry mimeUtil;
   private final Provider<String> webUrl;
@@ -276,17 +272,17 @@
     }
 
     if (file.startsWith(holder.staticPrefix)) {
-      JarFile jar = jarFileOf(holder.plugin);
-      if (jar != null) {
-        JarEntry entry = jar.getJarEntry(file);
-        if (exists(entry)) {
-          sendResource(jar, entry, key, res);
+      if (holder.plugin.getApiType() == ApiType.JS) {
+        sendJsPlugin(holder.plugin, key, req, res);
+      } else {
+        PluginContentScanner scanner = holder.plugin.getContentScanner();
+        Optional<PluginEntry> entry = scanner.getEntry(file);
+        if (entry.isPresent()) {
+          sendResource(scanner, entry.get(), key, res);
         } else {
           resourceCache.put(key, Resource.NOT_FOUND);
           Resource.NOT_FOUND.send(req, res);
         }
-      } else {
-        sendJsPlugin(holder.plugin, key, req, res);
       }
     } else if (file.equals(
         holder.docPrefix.substring(0, holder.docPrefix.length() - 1))) {
@@ -294,19 +290,19 @@
     } else if (file.startsWith(holder.docPrefix) && file.endsWith("/")) {
       res.sendRedirect(uri + "index.html");
     } else if (file.startsWith(holder.docPrefix)) {
-      JarFile jar = jarFileOf(holder.plugin);
-      JarEntry entry = jar.getJarEntry(file);
-      if (!exists(entry)) {
-        entry = findSource(jar, file);
+      PluginContentScanner scanner = holder.plugin.getContentScanner();
+      Optional<PluginEntry> entry = scanner.getEntry(file);
+      if (!entry.isPresent()) {
+        entry = findSource(scanner, file);
       }
-      if (!exists(entry) && file.endsWith("/index.html")) {
+      if (!entry.isPresent() && file.endsWith("/index.html")) {
         String pfx = file.substring(0, file.length() - "index.html".length());
-        sendAutoIndex(jar, pfx, holder.plugin.getName(), key, res,
+        sendAutoIndex(scanner, pfx, holder.plugin.getName(), key, res,
             holder.plugin.getSrcFile().lastModified());
-      } else if (exists(entry) && entry.getName().endsWith(".md")) {
-        sendMarkdownAsHtml(jar, entry, holder.plugin.getName(), key, res);
-      } else if (exists(entry)) {
-        sendResource(jar, entry, key, res);
+      } else if (entry.isPresent() && entry.get().getName().endsWith(".md")) {
+        sendMarkdownAsHtml(scanner, entry.get(), holder.plugin.getName(), key, res);
+      } else if (entry.isPresent()) {
+        sendResource(scanner, entry.get(), key, res);
       } else {
         resourceCache.put(key, Resource.NOT_FOUND);
         Resource.NOT_FOUND.send(req, res);
@@ -317,18 +313,18 @@
     }
   }
 
-  private void appendEntriesSection(JarFile jar, List<JarEntry> entries,
+  private void appendEntriesSection(PluginContentScanner scanner, List<PluginEntry> entries,
       String sectionTitle, StringBuilder md, String prefix,
       int nameOffset) throws IOException {
     if (!entries.isEmpty()) {
       md.append("## ").append(sectionTitle).append(" ##\n");
-      for(JarEntry entry : entries) {
+      for(PluginEntry entry : entries) {
         String rsrc = entry.getName().substring(prefix.length());
         String entryTitle;
         if (rsrc.endsWith(".html")) {
           entryTitle = rsrc.substring(nameOffset, rsrc.length() - 5).replace('-', ' ');
         } else if (rsrc.endsWith(".md")) {
-          entryTitle = extractTitleFromMarkdown(jar, entry);
+          entryTitle = extractTitleFromMarkdown(scanner, entry);
           if (Strings.isNullOrEmpty(entryTitle)) {
             entryTitle = rsrc.substring(nameOffset, rsrc.length() - 3).replace('-', ' ');
           }
@@ -342,24 +338,25 @@
     }
   }
 
-  private void sendAutoIndex(JarFile jar,
+  private void sendAutoIndex(PluginContentScanner scanner,
       String prefix, String pluginName,
-      ResourceKey cacheKey, HttpServletResponse res, long lastModifiedTime)
+      ResourceKey cacheKey, HttpServletResponse res,long lastModifiedTime)
       throws IOException {
-    List<JarEntry> cmds = Lists.newArrayList();
-    List<JarEntry> servlets = Lists.newArrayList();
-    List<JarEntry> restApis = Lists.newArrayList();
-    List<JarEntry> docs = Lists.newArrayList();
-    JarEntry about = null;
-    Enumeration<JarEntry> entries = jar.entries();
+    List<PluginEntry> cmds = Lists.newArrayList();
+    List<PluginEntry> servlets = Lists.newArrayList();
+    List<PluginEntry> restApis = Lists.newArrayList();
+    List<PluginEntry> docs = Lists.newArrayList();
+    PluginEntry about = null;
+    Enumeration<PluginEntry> entries = scanner.entries();
     while (entries.hasMoreElements()) {
-      JarEntry entry = entries.nextElement();
+      PluginEntry entry = entries.nextElement();
       String name = entry.getName();
-      long size = entry.getSize();
+      Optional<Long> size = entry.getSize();
       if (name.startsWith(prefix)
           && (name.endsWith(".md")
               || name.endsWith(".html"))
-          && 0 < size && size <= SMALL_RESOURCE) {
+              && size.isPresent()
+          && 0 < size.get() && size.get() <= SMALL_RESOURCE) {
         name = name.substring(prefix.length());
         if (name.startsWith("cmd-")) {
           cmds.add(entry);
@@ -377,16 +374,16 @@
       }
     }
 
-    Collections.sort(cmds, JAR_ENTRY_COMPARATOR_BY_NAME);
-    Collections.sort(docs, JAR_ENTRY_COMPARATOR_BY_NAME);
+    Collections.sort(cmds, PluginEntry.COMPARATOR_BY_NAME);
+    Collections.sort(docs, PluginEntry.COMPARATOR_BY_NAME);
 
     StringBuilder md = new StringBuilder();
     md.append(String.format("# Plugin %s #\n", pluginName));
     md.append("\n");
-    appendPluginInfoTable(md, jar.getManifest().getMainAttributes());
+    appendPluginInfoTable(md, scanner.getManifest().getMainAttributes());
 
     if (about != null) {
-      InputStreamReader isr = new InputStreamReader(jar.getInputStream(about));
+      InputStreamReader isr = new InputStreamReader(scanner.getInputStream(about));
       BufferedReader reader = new BufferedReader(isr);
       StringBuilder aboutContent = new StringBuilder();
       String line;
@@ -407,10 +404,10 @@
       }
     }
 
-    appendEntriesSection(jar, docs, "Documentation", md, prefix, 0);
-    appendEntriesSection(jar, servlets, "Servlets", md, prefix, "servlet-".length());
-    appendEntriesSection(jar, restApis, "REST APIs", md, prefix, "rest-api-".length());
-    appendEntriesSection(jar, cmds, "Commands", md, prefix, "cmd-".length());
+    appendEntriesSection(scanner, docs, "Documentation", md, prefix, 0);
+    appendEntriesSection(scanner, servlets, "Servlets", md, prefix, "servlet-".length());
+    appendEntriesSection(scanner, restApis, "REST APIs", md, prefix, "rest-api-".length());
+    appendEntriesSection(scanner, cmds, "Commands", md, prefix, "cmd-".length());
 
     sendMarkdownAsHtml(md.toString(), pluginName, cacheKey, res, lastModifiedTime);
   }
@@ -494,41 +491,38 @@
     }
   }
 
-  private static String extractTitleFromMarkdown(JarFile jar, JarEntry entry)
+  private static String extractTitleFromMarkdown(PluginContentScanner scanner, PluginEntry entry)
         throws IOException {
     String charEnc = null;
-    Attributes atts = entry.getAttributes();
+    Map<Object, String> atts = entry.getAttrs();
     if (atts != null) {
-      charEnc = Strings.emptyToNull(atts.getValue("Character-Encoding"));
+      charEnc = Strings.emptyToNull(atts.get(ATTR_CHARACTER_ENCODING));
     }
     if (charEnc == null) {
       charEnc = "UTF-8";
     }
     return new MarkdownFormatter().extractTitleFromMarkdown(
-          readWholeEntry(jar, entry),
+          readWholeEntry(scanner, entry),
           charEnc);
   }
 
-  private static JarEntry findSource(JarFile jar, String file) {
+  private static Optional<PluginEntry> findSource(
+      PluginContentScanner scanner, String file) throws IOException {
     if (file.endsWith(".html")) {
       int d = file.lastIndexOf('.');
-      return jar.getJarEntry(file.substring(0, d) + ".md");
+      return scanner.getEntry(file.substring(0, d) + ".md");
     }
-    return null;
+    return Optional.absent();
   }
 
-  private static boolean exists(JarEntry entry) {
-    return entry != null && entry.getSize() > 0;
-  }
-
-  private void sendMarkdownAsHtml(JarFile jar, JarEntry entry,
+  private void sendMarkdownAsHtml(PluginContentScanner scanner, PluginEntry entry,
       String pluginName, ResourceKey key, HttpServletResponse res)
       throws IOException {
-    byte[] rawmd = readWholeEntry(jar, entry);
+    byte[] rawmd = readWholeEntry(scanner, entry);
     String encoding = null;
-    Attributes atts = entry.getAttributes();
+    Map<Object, String> atts = entry.getAttrs();
     if (atts != null) {
-      encoding = Strings.emptyToNull(atts.getValue("Character-Encoding"));
+      encoding = Strings.emptyToNull(atts.get(ATTR_CHARACTER_ENCODING));
     }
 
     String txtmd = RawParseUtils.decode(
@@ -541,20 +535,21 @@
     sendMarkdownAsHtml(txtmd, pluginName, key, res, time);
   }
 
-  private void sendResource(JarFile jar, JarEntry entry,
+  private void sendResource(PluginContentScanner scanner, PluginEntry entry,
       ResourceKey key, HttpServletResponse res)
       throws IOException {
     byte[] data = null;
-    if (entry.getSize() <= SMALL_RESOURCE) {
-      data = readWholeEntry(jar, entry);
+    Optional<Long> size = entry.getSize();
+    if (size.isPresent() && size.get() <= SMALL_RESOURCE) {
+      data = readWholeEntry(scanner, entry);
     }
 
     String contentType = null;
     String charEnc = null;
-    Attributes atts = entry.getAttributes();
+    Map<Object, String> atts = entry.getAttrs();
     if (atts != null) {
-      contentType = Strings.emptyToNull(atts.getValue("Content-Type"));
-      charEnc = Strings.emptyToNull(atts.getValue("Character-Encoding"));
+      contentType = Strings.emptyToNull(atts.get(ATTR_CONTENT_TYPE));
+      charEnc = Strings.emptyToNull(atts.get(ATTR_CHARACTER_ENCODING));
     }
     if (contentType == null) {
       contentType = mimeUtil.getMimeType(entry.getName(), data).toString();
@@ -568,7 +563,9 @@
     if (0 < time) {
       res.setDateHeader("Last-Modified", time);
     }
-    res.setHeader("Content-Length", Long.toString(entry.getSize()));
+    if (size.isPresent()) {
+      res.setHeader("Content-Length", size.get().toString());
+    }
     res.setContentType(contentType);
     if (charEnc != null) {
       res.setCharacterEncoding(charEnc);
@@ -580,7 +577,7 @@
           .setLastModified(time));
       res.getOutputStream().write(data);
     } else {
-      writeToResponse(res, jar.getInputStream(entry));
+      writeToResponse(res, scanner.getInputStream(entry));
     }
   }
 
@@ -620,10 +617,10 @@
     }
   }
 
-  private static byte[] readWholeEntry(JarFile jar, JarEntry entry)
+  private static byte[] readWholeEntry(PluginContentScanner scanner, PluginEntry entry)
       throws IOException {
-    byte[] data = new byte[(int) entry.getSize()];
-    InputStream in = jar.getInputStream(entry);
+    byte[] data = new byte[entry.getSize().get().intValue()];
+    InputStream in = scanner.getInputStream(entry);
     try {
       IO.readFully(in, data, 0, data.length);
     } finally {
@@ -632,14 +629,6 @@
     return data;
   }
 
-  private static JarFile jarFileOf(Plugin plugin) {
-    if(plugin instanceof ServerPlugin) {
-      return ((ServerPlugin) plugin).getJarFile();
-    } else {
-      return null;
-    }
-  }
-
   private static class PluginHolder {
     final Plugin plugin;
     final GuiceFilter filter;
@@ -656,13 +645,14 @@
     }
 
     private static String getPrefix(Plugin plugin, String attr, String def) {
-      JarFile jarFile = jarFileOf(plugin);
-      if (jarFile == null) {
+      File srcFile = plugin.getSrcFile();
+      PluginContentScanner scanner = plugin.getContentScanner();
+      if (srcFile == null || scanner == PluginContentScanner.EMPTY) {
         return def;
       }
       try {
-        String prefix = jarFile.getManifest().getMainAttributes()
-            .getValue(attr);
+        String prefix =
+            scanner.getManifest().getMainAttributes().getValue(attr);
         if (prefix != null) {
           return CharMatcher.is('/').trimFrom(prefix) + "/";
         } else {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
index 6ce3464..31a7c98 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -299,7 +299,7 @@
 
   private PluginEntry resourceOf(JarEntry jarEntry) throws IOException {
     return new PluginEntry(jarEntry.getName(), jarEntry.getTime(),
-        jarEntry.getSize(), attributesOf(jarEntry));
+        Optional.of(jarEntry.getSize()), attributesOf(jarEntry));
   }
 
   private Map<Object, String> attributesOf(JarEntry jarEntry)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java
index 6022f02..7242e98 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginEntry.java
@@ -13,7 +13,10 @@
 // limitations under the License.
 package com.google.gerrit.server.plugins;
 
+import com.google.common.base.Optional;
+
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.Map;
 
 /**
@@ -26,15 +29,23 @@
 public class PluginEntry {
   public static final String ATTR_CHARACTER_ENCODING = "Character-Encoding";
   public static final String ATTR_CONTENT_TYPE = "Content-Type";
+  public static final Comparator<PluginEntry> COMPARATOR_BY_NAME =
+      new Comparator<PluginEntry>() {
+        @Override
+        public int compare(PluginEntry a, PluginEntry b) {
+          return a.getName().compareTo(b.getName());
+        }
+      };
 
-  private static final Map<Object,String> EMPTY_ATTRS = Collections.emptyMap();
+  private static final Map<Object, String> EMPTY_ATTRS = Collections.emptyMap();
+  private static final Optional<Long> NO_SIZE = Optional.absent();
 
   private final String name;
   private final long time;
-  private final long size;
+  private final Optional<Long> size;
   private final Map<Object, String> attrs;
 
-  public PluginEntry(String name, long time, long size,
+  public PluginEntry(String name, long time, Optional<Long> size,
       Map<Object, String> attrs) {
     this.name = name;
     this.time = time;
@@ -42,10 +53,14 @@
     this.attrs = attrs;
   }
 
-  public PluginEntry(String name, long time, long size) {
+  public PluginEntry(String name, long time, Optional<Long> size) {
     this(name, time, size, EMPTY_ATTRS);
   }
 
+  public PluginEntry(String name, long time) {
+    this(name, time, NO_SIZE, EMPTY_ATTRS);
+  }
+
   public String getName() {
     return name;
   }
@@ -54,11 +69,11 @@
     return time;
   }
 
-  public long getSize() {
+  public Optional<Long> getSize() {
     return size;
   }
 
   public Map<Object, String> getAttrs() {
     return attrs;
   }
-}
\ No newline at end of file
+}