Add support for HTTP plugins

Plugins may contribute to the /plugins/NAME/ URL space by providing
a ServletModule in the manifest using Gerrit-HttpModule and binding
servlets and filters using Guice bindings.

All names are relative to the plugin's directory, so

  serve("/").with(IndexServlet.class);

will handle /plugins/NAME/ and not "/" on the server. This makes a
plugin automatically relocatable to match its SSH command name or
the name in $site_dir/plugins.

Change-Id: I17e3007f0310d2bf4989d652f18864a77c5d5f2e
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
new file mode 100644
index 0000000..1de330f
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginModule.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// 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 com.google.gerrit.httpd.plugins;
+
+import com.google.gerrit.server.plugins.StartPluginListener;
+import com.google.inject.internal.UniqueAnnotations;
+import com.google.inject.servlet.ServletModule;
+
+public class HttpPluginModule extends ServletModule {
+  @Override
+  protected void configureServlets() {
+    serve("/plugins/*").with(HttpPluginServlet.class);
+
+    bind(StartPluginListener.class)
+      .annotatedWith(UniqueAnnotations.create())
+      .to(HttpPluginServlet.class);
+  }
+}
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
new file mode 100644
index 0000000..b73d6e6
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -0,0 +1,166 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// 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 com.google.gerrit.httpd.plugins;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.gerrit.server.plugins.Plugin;
+import com.google.gerrit.server.plugins.RegistrationHandle;
+import com.google.gerrit.server.plugins.StartPluginListener;
+import com.google.inject.Singleton;
+import com.google.inject.servlet.GuiceFilter;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.ConcurrentMap;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+class HttpPluginServlet extends HttpServlet
+    implements StartPluginListener {
+  private static final long serialVersionUID = 1L;
+  private static final Logger log
+      = LoggerFactory.getLogger(HttpPluginServlet.class);
+
+  private List<Plugin> pending = Lists.newArrayList();
+  private String base;
+  private final ConcurrentMap<String, GuiceFilter> plugins
+      = Maps.newConcurrentMap();
+
+  @Override
+  public synchronized void init(ServletConfig config) throws ServletException {
+    super.init(config);
+
+    String path = config.getServletContext().getContextPath();
+    base = Strings.nullToEmpty(path) + "/plugins/";
+    for (Plugin plugin : pending) {
+      start(plugin);
+    }
+    pending = null;
+  }
+
+  @Override
+  public synchronized void onStartPlugin(Plugin plugin) {
+    if (pending != null) {
+      pending.add(plugin);
+    } else {
+      start(plugin);
+    }
+  }
+
+  private void start(Plugin plugin) {
+    if (plugin.getHttpInjector() != null) {
+      final String name = plugin.getName();
+      final GuiceFilter filter;
+      try {
+        filter = plugin.getHttpInjector().getInstance(GuiceFilter.class);
+      } catch (RuntimeException e) {
+        log.warn(String.format("Plugin %s cannot load GuiceFilter", name), e);
+        return;
+      }
+
+      try {
+        WrappedContext ctx = new WrappedContext(plugin, base + name);
+        filter.init(new WrappedFilterConfig(ctx));
+      } catch (ServletException e) {
+        log.warn(String.format("Plugin %s failed to initialize HTTP", name), e);
+        return;
+      }
+
+      plugin.add(new RegistrationHandle() {
+        @Override
+        public void remove() {
+          try {
+            filter.destroy();
+          } finally {
+            plugins.remove(name, filter);
+          }
+        }
+      });
+      plugins.put(name, filter);
+    }
+  }
+
+  @Override
+  public void service(HttpServletRequest req, HttpServletResponse res)
+      throws IOException, ServletException {
+    String name = extractName(req);
+    GuiceFilter filter = plugins.get(name);
+    if (filter == 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);
+          }
+        });
+  }
+
+  private static String extractName(HttpServletRequest req) {
+    String path = req.getPathInfo();
+    if (Strings.isNullOrEmpty(path) || "/".equals(path)) {
+      return "";
+    }
+    int s = path.indexOf('/', 1);
+    return 0 <= s ? path.substring(1, s) : path.substring(1);
+  }
+
+  private static void noCache(HttpServletResponse res) {
+    res.setHeader("Expires", "Fri, 01 Jan 1980 00:00:00 GMT");
+    res.setHeader("Pragma", "no-cache");
+    res.setHeader("Cache-Control", "no-cache, must-revalidate");
+    res.setHeader("Content-Disposition", "attachment");
+  }
+
+  private static class WrappedRequest extends HttpServletRequestWrapper {
+    private final String contextPath;
+
+    WrappedRequest(HttpServletRequest req, String contextPath) {
+      super(req);
+      this.contextPath = contextPath;
+    }
+
+    @Override
+    public String getContextPath() {
+      return contextPath;
+    }
+
+    @Override
+    public String getServletPath() {
+      return ((HttpServletRequest) getRequest()).getRequestURI();
+    }
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedContext.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedContext.java
new file mode 100644
index 0000000..daeb6ff
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedContext.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// 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 com.google.gerrit.httpd.plugins;
+
+import com.google.common.collect.Maps;
+import com.google.gerrit.common.Version;
+import com.google.gerrit.server.plugins.Plugin;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.Set;
+import java.util.concurrent.ConcurrentMap;
+
+import javax.servlet.RequestDispatcher;
+import javax.servlet.Servlet;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+
+class WrappedContext implements ServletContext {
+  private static final Logger log = LoggerFactory.getLogger("plugin");
+  private final Plugin plugin;
+  private final String contextPath;
+  private final ConcurrentMap<String, Object> attributes;
+
+  WrappedContext(Plugin plugin, String contextPath) {
+    this.plugin = plugin;
+    this.contextPath = contextPath;
+    this.attributes = Maps.newConcurrentMap();
+  }
+
+  @Override
+  public String getContextPath() {
+    return contextPath;
+  }
+
+  @Override
+  public String getInitParameter(String name) {
+    return null;
+  }
+
+  @SuppressWarnings("rawtypes")
+  @Override
+  public Enumeration getInitParameterNames() {
+    return Collections.enumeration(Collections.emptyList());
+  }
+
+  @Override
+  public ServletContext getContext(String name) {
+    return null;
+  }
+
+  @Override
+  public RequestDispatcher getNamedDispatcher(String name) {
+    return null;
+  }
+
+  @Override
+  public RequestDispatcher getRequestDispatcher(String name) {
+    return null;
+  }
+
+  @Override
+  public URL getResource(String name) throws MalformedURLException {
+    return null;
+  }
+
+  @Override
+  public InputStream getResourceAsStream(String name) {
+    return null;
+  }
+
+  @SuppressWarnings("rawtypes")
+  @Override
+  public Set getResourcePaths(String name) {
+    return null;
+  }
+
+  @Override
+  public Servlet getServlet(String name) throws ServletException {
+    return null;
+  }
+
+  @Override
+  public String getRealPath(String name) {
+    return null;
+  }
+
+  @Override
+  public String getServletContextName() {
+    return plugin.getName();
+  }
+
+  @SuppressWarnings("rawtypes")
+  @Override
+  public Enumeration getServletNames() {
+    return Collections.enumeration(Collections.emptyList());
+  }
+
+  @SuppressWarnings("rawtypes")
+  @Override
+  public Enumeration getServlets() {
+    return Collections.enumeration(Collections.emptyList());
+  }
+
+  @Override
+  public void log(Exception reason, String msg) {
+    log(msg, reason);
+  }
+
+  @Override
+  public void log(String msg) {
+    log(msg, null);
+  }
+
+  @Override
+  public void log(String msg, Throwable reason) {
+    log.warn(String.format("[plugin %s] %s", plugin.getName(), msg), reason);
+  }
+
+  @Override
+  public Object getAttribute(String name) {
+    return attributes.get(name);
+  }
+
+  @Override
+  public Enumeration<String> getAttributeNames() {
+    return Collections.enumeration(attributes.keySet());
+  }
+
+  @Override
+  public void setAttribute(String name, Object value) {
+    attributes.put(name, value);
+  }
+
+  @Override
+  public void removeAttribute(String name) {
+    attributes.remove(name);
+  }
+
+  @Override
+  public String getMimeType(String file) {
+    return null;
+  }
+
+  @Override
+  public int getMajorVersion() {
+    return 2;
+  }
+
+  @Override
+  public int getMinorVersion() {
+    return 5;
+  }
+
+  @Override
+  public String getServerInfo() {
+    String v = Version.getVersion();
+    return "Gerrit Code Review/" + (v != null ? v : "dev");
+  }
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java
new file mode 100644
index 0000000..c9107dc
--- /dev/null
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/plugins/WrappedFilterConfig.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// 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 com.google.gerrit.httpd.plugins;
+
+import com.google.inject.servlet.GuiceFilter;
+
+import java.util.Collections;
+import java.util.Enumeration;
+
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletContext;
+
+class WrappedFilterConfig implements FilterConfig {
+  private final WrappedContext context;
+
+  WrappedFilterConfig(WrappedContext context) {
+    this.context = context;
+  }
+
+  @Override
+  public String getFilterName() {
+    return GuiceFilter.class.getName();
+  }
+
+  @Override
+  public String getInitParameter(String name) {
+    return null;
+  }
+
+  @SuppressWarnings("rawtypes")
+  @Override
+  public Enumeration getInitParameterNames() {
+    return Collections.enumeration(Collections.emptyList());
+  }
+
+  @Override
+  public ServletContext getServletContext() {
+    return context;
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
index 85b1012..bbff5cb 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.httpd.WebModule;
 import com.google.gerrit.httpd.WebSshGlueModule;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
+import com.google.gerrit.httpd.plugins.HttpPluginModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.http.jetty.GetUserFilter;
 import com.google.gerrit.pgm.http.jetty.JettyEnv;
@@ -259,6 +260,9 @@
   private void initHttpd() {
     webInjector = createWebInjector();
 
+    sysInjector.getInstance(PluginGuiceEnvironment.class)
+        .setHttpInjector(webInjector);
+
     sysInjector.getInstance(HttpCanonicalWebUrlProvider.class)
         .setHttpServletRequest(
             webInjector.getProvider(HttpServletRequest.class));
@@ -273,6 +277,7 @@
     modules.add(HttpContactStoreConnection.module());
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
     modules.add(sysInjector.getInstance(WebModule.class));
+    modules.add(new HttpPluginModule());
     if (sshd) {
       modules.add(sshInjector.getInstance(WebSshGlueModule.class));
       modules.add(new ProjectQoSFilter.Module());
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index fa5ef59..a57de3c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -40,6 +40,7 @@
 import org.eclipse.jetty.server.nio.SelectChannelConnector;
 import org.eclipse.jetty.server.ssl.SslSelectChannelConnector;
 import org.eclipse.jetty.servlet.DefaultServlet;
+import org.eclipse.jetty.servlet.FilterHolder;
 import org.eclipse.jetty.servlet.FilterMapping;
 import org.eclipse.jetty.servlet.ServletContextHandler;
 import org.eclipse.jetty.servlet.ServletHolder;
@@ -328,7 +329,8 @@
     // of using the listener to create the injector pass the one we
     // already have built.
     //
-    app.addFilter(GuiceFilter.class, "/*", FilterMapping.DEFAULT);
+    GuiceFilter filter = env.webInjector.getInstance(GuiceFilter.class);
+    app.addFilter(new FilterHolder(filter), "/*", FilterMapping.DEFAULT);
     app.addEventListener(new GuiceServletContextListener() {
       @Override
       protected Injector getInjector() {
diff --git a/gerrit-plugin-api/pom.xml b/gerrit-plugin-api/pom.xml
index e0c1514..5c4ca3449 100644
--- a/gerrit-plugin-api/pom.xml
+++ b/gerrit-plugin-api/pom.xml
@@ -38,6 +38,17 @@
       <artifactId>gerrit-sshd</artifactId>
       <version>${project.version}</version>
     </dependency>
+
+    <dependency>
+      <groupId>com.google.gerrit</groupId>
+      <artifactId>gerrit-httpd</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+
+    <dependency>
+      <groupId>org.apache.tomcat</groupId>
+      <artifactId>servlet-api</artifactId>
+    </dependency>
   </dependencies>
 
   <build>
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 0c1ab0f..e9a6308 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
@@ -20,6 +20,7 @@
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import com.google.inject.Module;
+import com.google.inject.servlet.GuiceFilter;
 
 import org.eclipse.jgit.storage.file.FileSnapshot;
 
@@ -30,15 +31,24 @@
 import javax.annotation.Nullable;
 
 public class Plugin {
+  static {
+    // Guice logs warnings about multiple injectors being created.
+    // Silence this in case HTTP plugins are used.
+    java.util.logging.Logger.getLogger(GuiceFilter.class.getName())
+        .setLevel(java.util.logging.Level.OFF);
+  }
+
   private final String name;
   private final File jar;
   private final Manifest manifest;
   private final FileSnapshot snapshot;
   private Class<? extends Module> sysModule;
   private Class<? extends Module> sshModule;
+  private Class<? extends Module> httpModule;
 
   private Injector sysInjector;
   private Injector sshInjector;
+  private Injector httpInjector;
   private LifecycleManager manager;
 
   public Plugin(String name,
@@ -46,13 +56,15 @@
       Manifest manifest,
       FileSnapshot snapshot,
       @Nullable Class<? extends Module> sysModule,
-      @Nullable Class<? extends Module> sshModule) {
+      @Nullable Class<? extends Module> sshModule,
+      @Nullable Class<? extends Module> httpModule) {
     this.name = name;
     this.jar = jar;
     this.manifest = manifest;
     this.snapshot = snapshot;
     this.sysModule = sysModule;
     this.sshModule = sshModule;
+    this.httpModule = httpModule;
   }
 
   File getJar() {
@@ -90,6 +102,13 @@
       manager.add(sshInjector);
     }
 
+    if (httpModule != null && env.hasHttpModule()) {
+      httpInjector = sysInjector.createChildInjector(
+          env.getHttpModule(),
+          sysInjector.getInstance(httpModule));
+      manager.add(httpInjector);
+    }
+
     manager.start();
     env.onStartPlugin(this);
   }
@@ -113,6 +132,7 @@
       manager = null;
       sysInjector = null;
       sshInjector = null;
+      httpInjector = null;
     }
   }
 
@@ -121,6 +141,11 @@
     return sshInjector;
   }
 
+  @Nullable
+  public Injector getHttpInjector() {
+    return httpInjector;
+  }
+
   public void add(final RegistrationHandle handle) {
     add(new LifecycleListener() {
       @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index 418fbf2..4b6f497 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -45,6 +45,7 @@
   private final List<StartPluginListener> listeners;
   private Module sysModule;
   private Module sshModule;
+  private Module httpModule;
 
   @Inject
   PluginGuiceEnvironment(Injector sysInjector, CopyConfigModule ccm) {
@@ -84,6 +85,19 @@
     return sshModule;
   }
 
+  public void setHttpInjector(Injector httpInjector) {
+    httpModule = copy(httpInjector);
+    listeners.addAll(getListeners(httpInjector));
+  }
+
+  boolean hasHttpModule() {
+    return httpModule != null;
+  }
+
+  Module getHttpModule() {
+    return httpModule;
+  }
+
   void onStartPlugin(Plugin plugin) {
     for (StartPluginListener l : listeners) {
       l.onStartPlugin(plugin);
@@ -126,15 +140,74 @@
 
   private static boolean shouldCopy(Key<?> key) {
     Class<?> type = key.getTypeLiteral().getRawType();
-    if (type == LifecycleListener.class) {
+    if (LifecycleListener.class.isAssignableFrom(type)) {
       return false;
     }
-    if (type == StartPluginListener.class) {
+    if (StartPluginListener.class.isAssignableFrom(type)) {
       return false;
     }
-    if ("org.apache.sshd.server.Command".equals(type.getName())) {
+
+    if (type.getName().startsWith("com.google.inject.")) {
+      return false;
+    }
+
+    if (is("org.apache.sshd.server.Command", type)) {
+      return false;
+    }
+
+    if (is("javax.servlet.Filter", type)) {
+      return false;
+    }
+    if (is("javax.servlet.ServletContext", type)) {
+      return false;
+    }
+    if (is("javax.servlet.ServletRequest", type)) {
+      return false;
+    }
+    if (is("javax.servlet.ServletResponse", type)) {
+      return false;
+    }
+    if (is("javax.servlet.http.HttpServlet", type)) {
+      return false;
+    }
+    if (is("javax.servlet.http.HttpServletRequest", type)) {
+      return false;
+    }
+    if (is("javax.servlet.http.HttpServletResponse", type)) {
+      return false;
+    }
+    if (is("javax.servlet.http.HttpSession", type)) {
+      return false;
+    }
+    if (Map.class.isAssignableFrom(type)
+        && key.getAnnotationType() != null
+        && "com.google.inject.servlet.RequestParameters"
+            .equals(key.getAnnotationType().getName())) {
+      return false;
+    }
+    if (type.getName().startsWith("com.google.gerrit.httpd.GitOverHttpServlet$")) {
       return false;
     }
     return true;
   }
+
+  private static boolean is(String name, Class<?> type) {
+    Class<?> p = type;
+    while (p != null) {
+      if (name.equals(p.getName())) {
+        return true;
+      }
+      p = p.getSuperclass();
+    }
+
+    Class<?>[] interfaces = type.getInterfaces();
+    if (interfaces != null) {
+      for (Class<?> i : interfaces) {
+        if (is(name, i)) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
 }
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 44b2f12..2ee6b04 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
@@ -245,6 +245,7 @@
     Attributes main = manifest.getMainAttributes();
     String sysName = main.getValue("Gerrit-Module");
     String sshName = main.getValue("Gerrit-SshModule");
+    String httpName = main.getValue("Gerrit-HttpModule");
 
     URL[] urls = {jarFile.toURI().toURL()};
     ClassLoader parentLoader = PluginLoader.class.getClassLoader();
@@ -252,7 +253,9 @@
 
     Class<? extends Module> sysModule = load(sysName, pluginLoader);
     Class<? extends Module> sshModule = load(sshName, pluginLoader);
-    return new Plugin(name, jarFile, manifest, snapshot, sysModule, sshModule);
+    Class<? extends Module> httpModule = load(httpName, pluginLoader);
+    return new Plugin(name, jarFile, manifest, snapshot,
+        sysModule, sshModule, httpModule);
   }
 
   private Class<? extends Module> load(String name, ClassLoader pluginLoader)
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
index 0c15dfd..8db75e2 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.common.ChangeHookRunner;
 import com.google.gerrit.ehcache.EhcachePoolImpl;
 import com.google.gerrit.httpd.auth.openid.OpenIdModule;
+import com.google.gerrit.httpd.plugins.HttpPluginModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.reviewdb.client.AuthType;
@@ -117,6 +118,7 @@
       PluginGuiceEnvironment env = sysInjector.getInstance(PluginGuiceEnvironment.class);
       env.setCfgInjector(cfgInjector);
       env.setSshInjector(sshInjector);
+      env.setHttpInjector(webInjector);
 
       // Push the Provider<HttpServletRequest> down into the canonical
       // URL provider. Its optional for that provider, but since we can
@@ -228,6 +230,7 @@
     modules.add(sshInjector.getInstance(WebSshGlueModule.class));
     modules.add(CacheBasedWebSession.module());
     modules.add(HttpContactStoreConnection.module());
+    modules.add(new HttpPluginModule());
 
     AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
     if (authConfig.getAuthType() == AuthType.OPENID) {