Add ROOTED_DOC view to serve Markdown at root of a host

This is to support serving [1] at https://gerritcodereview.com/ without
having the messy prefix of "homepage/+doc/HEAD" in the path. Google is
hosting this DNS name and will run a server installing RootedDocServlet
at "/" for gerritcodereview.com. This will mount and serve Markdown,
making a new homepage for Gerrit.

When running the DevServer set gitiles.docroot to the path of a bare
Git repository to run the documentation server.

In either case content is served from the "md-pages" branch.

[1] https://gerrit.googlesource.com/homepage/+doc/HEAD/

Change-Id: I73ae4451586a5acbb9187b3c4dd13a05e88a1926
diff --git a/gitiles-dev/src/main/java/com/google/gitiles/dev/DevServer.java b/gitiles-dev/src/main/java/com/google/gitiles/dev/DevServer.java
index 6d49d4e..f3f6597 100644
--- a/gitiles-dev/src/main/java/com/google/gitiles/dev/DevServer.java
+++ b/gitiles-dev/src/main/java/com/google/gitiles/dev/DevServer.java
@@ -17,9 +17,14 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.gitiles.GitilesServlet.STATIC_PREFIX;
 
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
 import com.google.gitiles.DebugRenderer;
+import com.google.gitiles.GitilesAccess;
 import com.google.gitiles.GitilesServlet;
 import com.google.gitiles.PathServlet;
+import com.google.gitiles.RepositoryDescription;
+import com.google.gitiles.RootedDocServlet;
 
 import org.eclipse.jetty.server.Connector;
 import org.eclipse.jetty.server.Handler;
@@ -34,8 +39,14 @@
 import org.eclipse.jetty.util.thread.QueuedThreadPool;
 import org.eclipse.jetty.util.thread.ThreadPool;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.transport.resolver.RepositoryResolver;
 import org.eclipse.jgit.util.FS;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -48,6 +59,12 @@
 import java.net.URISyntaxException;
 import java.net.UnknownHostException;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+
+import javax.servlet.Servlet;
+import javax.servlet.http.HttpServletRequest;
 
 class DevServer {
   private static final Logger log = LoggerFactory.getLogger(PathServlet.class);
@@ -184,15 +201,23 @@
   }
 
   private Handler appHandler() {
-    GitilesServlet servlet = new GitilesServlet(
-        cfg,
-        new DebugRenderer(
-            STATIC_PREFIX,
-            Arrays.asList(cfg.getStringList("gitiles", null, "customTemplates")),
-            new File(sourceRoot, "gitiles-servlet/src/main/resources/com/google/gitiles/templates")
-                .getPath(),
-            firstNonNull(cfg.getString("gitiles", null, "siteTitle"), "Gitiles")),
-        null, null, null, null, null, null, null);
+    DebugRenderer renderer = new DebugRenderer(
+        STATIC_PREFIX,
+        Arrays.asList(cfg.getStringList("gitiles", null, "customTemplates")),
+        new File(sourceRoot, "gitiles-servlet/src/main/resources/com/google/gitiles/templates")
+            .getPath(),
+        firstNonNull(cfg.getString("gitiles", null, "siteTitle"), "Gitiles"));
+
+    String docRoot = cfg.getString("gitiles", null, "docroot");
+    Servlet servlet;
+    if (!Strings.isNullOrEmpty(docRoot)) {
+      servlet = createRootedDocServlet(renderer, docRoot);
+    } else {
+      servlet = new GitilesServlet(
+          cfg,
+          renderer,
+          null, null, null, null, null, null, null);
+    }
 
     ServletContextHandler handler = new ServletContextHandler();
     handler.setContextPath("");
@@ -215,4 +240,71 @@
     handler.setHandler(rh);
     return handler;
   }
+
+  private Servlet createRootedDocServlet(DebugRenderer renderer, String docRoot) {
+    File docRepo = new File(docRoot);
+    final FileKey repoKey = FileKey.exact(docRepo, FS.DETECTED);
+
+    RepositoryResolver<HttpServletRequest> resolver = new RepositoryResolver<HttpServletRequest>() {
+      @Override
+      public Repository open(HttpServletRequest req, String name)
+          throws RepositoryNotFoundException {
+        try {
+          return RepositoryCache.open(repoKey, true);
+        } catch (IOException e) {
+          throw new RepositoryNotFoundException(repoKey.getFile(), e);
+        }
+      }
+    };
+
+    return new RootedDocServlet(
+        resolver,
+        new RootedDocAccess(docRepo),
+        renderer);
+  }
+
+  private class RootedDocAccess implements GitilesAccess.Factory {
+    private final String repoName;
+
+    RootedDocAccess(File docRepo) {
+      if (Constants.DOT_GIT.equals(docRepo.getName())) {
+        repoName = docRepo.getParentFile().getName();
+      } else {
+        repoName = docRepo.getName();
+      }
+    }
+
+    @Override
+    public GitilesAccess forRequest(HttpServletRequest req) {
+      return new GitilesAccess() {
+        @Override
+        public Map<String, RepositoryDescription> listRepositories(Set<String> branches) {
+          return Collections.emptyMap();
+        }
+
+        @Override
+        public Object getUserKey() {
+          return null;
+        }
+
+        @Override
+        public String getRepositoryName() {
+          return repoName;
+        }
+
+        @Override
+        public RepositoryDescription getRepositoryDescription() {
+          RepositoryDescription d = new RepositoryDescription();
+          d.name = getRepositoryName();
+          return d;
+        }
+
+        @Override
+        public Config getConfig() {
+          return cfg;
+        }
+      };
+    }
+  }
+
 }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
index ddb0abf..2734380 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
@@ -258,6 +258,7 @@
       case BLAME:
         return new BlameServlet(accessFactory, renderer, blameCache);
       case DOC:
+      case ROOTED_DOC:
         return new DocServlet(accessFactory, renderer);
       default:
         throw new IllegalArgumentException("Invalid view type: " + view);
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
index a6df07a..aac9f08 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
@@ -67,7 +67,8 @@
     DESCRIBE,
     ARCHIVE,
     BLAME,
-    DOC;
+    DOC,
+    ROOTED_DOC;
   }
 
   /** Exception thrown when building a view that is invalid. */
@@ -81,7 +82,7 @@
 
   /** Builder for views. */
   public static class Builder {
-    private final Type type;
+    private Type type;
     private final ListMultimap<String, String> params = LinkedListMultimap.create();
 
     private String hostName;
@@ -98,6 +99,10 @@
     }
 
     public Builder copyFrom(GitilesView other) {
+      if (type == Type.DOC && other.type == Type.ROOTED_DOC) {
+        type = Type.ROOTED_DOC;
+      }
+
       hostName = other.hostName;
       servletPath = other.servletPath;
       switch (type) {
@@ -107,6 +112,7 @@
           // Fallthrough.
         case PATH:
         case DOC:
+        case ROOTED_DOC:
         case ARCHIVE:
         case BLAME:
         case SHOW:
@@ -238,6 +244,7 @@
         case REFS:
         case LOG:
         case DOC:
+        case ROOTED_DOC:
           break;
         default:
           checkState(path == null, "cannot set path on %s view", type);
@@ -332,6 +339,8 @@
           break;
         case DOC:
           checkDoc();
+        case ROOTED_DOC:
+          checkRootedDoc();
           break;
       }
       return new GitilesView(type, hostName, servletPath, repositoryName, revision,
@@ -395,6 +404,13 @@
     private void checkDoc() {
       checkRevision();
     }
+
+    private void checkRootedDoc() {
+      checkView(hostName != null, "missing hostName on %s view", type);
+      checkView(servletPath != null, "missing hostName on %s view", type);
+      checkView(revision != Revision.NULL, "missing revision on %s view", type);
+      checkView(path != null, "missing path on %s view", type);
+    }
   }
 
   public static Builder hostIndex() {
@@ -445,6 +461,10 @@
     return new Builder(Type.DOC);
   }
 
+  public static Builder rootedDoc() {
+    return new Builder(Type.ROOTED_DOC);
+  }
+
   static String maybeTrimLeadingAndTrailingSlash(String str) {
     if (str.startsWith("/")) {
       str = str.substring(1);
@@ -648,6 +668,11 @@
           url.append('/').append(path);
         }
         break;
+      case ROOTED_DOC:
+        if (path != null) {
+          url.append(path);
+        }
+        break;
       default:
         throw new IllegalStateException("Unknown view type: " + type);
     }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RootedDocServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RootedDocServlet.java
new file mode 100644
index 0000000..bdf4aa9
--- /dev/null
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/RootedDocServlet.java
@@ -0,0 +1,97 @@
+// Copyright 2015 Google Inc. All Rights Reserved.
+//
+// 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.gitiles;
+
+import static org.eclipse.jgit.http.server.ServletUtils.ATTRIBUTE_REPOSITORY;
+
+import com.google.gitiles.doc.DocServlet;
+
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.resolver.RepositoryResolver;
+import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
+import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/** Serves Markdown at the root of a host. */
+public class RootedDocServlet extends HttpServlet {
+  private static final Logger log = LoggerFactory.getLogger(ViewFilter.class);
+  private static final long serialVersionUID = 1L;
+  public static final String BRANCH = "refs/heads/md-pages";
+
+  private final RepositoryResolver<HttpServletRequest> resolver;
+  private final DocServlet docServlet;
+
+  public RootedDocServlet(RepositoryResolver<HttpServletRequest> resolver,
+      GitilesAccess.Factory accessFactory, Renderer renderer) {
+    this.resolver = resolver;
+    docServlet = new DocServlet(accessFactory, renderer);
+  }
+
+  @Override
+  public void init(ServletConfig config) throws ServletException {
+    super.init(config);
+    docServlet.init(config);
+  }
+
+  @Override
+  public void service(HttpServletRequest req, HttpServletResponse res)
+      throws IOException, ServletException {
+    try (Repository repo = resolver.open(req, null);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId id = repo.resolve(BRANCH);
+      if (id == null) {
+        res.sendError(HttpServletResponse.SC_NOT_FOUND);
+        return;
+      }
+
+      RevObject obj = rw.peel(rw.parseAny(id));
+      if (!(obj instanceof RevCommit)) {
+        res.sendError(HttpServletResponse.SC_NOT_FOUND);
+        return;
+      }
+
+      req.setAttribute(ATTRIBUTE_REPOSITORY, repo);
+      ViewFilter.setView(req, GitilesView.rootedDoc()
+          .setHostName(req.getServerName())
+          .setServletPath(req.getContextPath() + req.getServletPath())
+          .setRevision(BRANCH, obj)
+          .setPathPart(req.getPathInfo())
+          .build());
+
+      docServlet.service(req, res);
+    } catch (RepositoryNotFoundException | ServiceNotAuthorizedException
+        | ServiceNotEnabledException e) {
+      log.error(String.format("cannot open repository for %s", req.getServerName()), e);
+      res.sendError(HttpServletResponse.SC_NOT_FOUND);
+    } finally {
+      ViewFilter.removeView(req);
+      req.removeAttribute(ATTRIBUTE_REPOSITORY);
+    }
+  }
+}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
index d8a0420..390a271 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
@@ -67,6 +67,10 @@
     req.setAttribute(VIEW_ATTRIBUTE, view);
   }
 
+  static void removeView(HttpServletRequest req) {
+    req.removeAttribute(VIEW_ATTRIBUTE);
+  }
+
   static String trimLeadingSlash(String str) {
     return checkLeadingSlash(str).substring(1);
   }
@@ -121,7 +125,7 @@
     try {
       chain.doFilter(req, res);
     } finally {
-      req.removeAttribute(VIEW_ATTRIBUTE);
+      removeView(req);
     }
   }
 
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java
index 1f96847..fb922dd 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java
@@ -175,9 +175,11 @@
     data.put("pageTitle", MoreObjects.firstNonNull(
         MarkdownUtil.getTitle(doc),
         view.getPathPart()));
-    data.put("sourceUrl", GitilesView.show().copyFrom(view).toUrl());
-    data.put("logUrl", GitilesView.log().copyFrom(view).toUrl());
-    data.put("blameUrl", GitilesView.blame().copyFrom(view).toUrl());
+    if (view.getType() != GitilesView.Type.ROOTED_DOC) {
+      data.put("sourceUrl", GitilesView.show().copyFrom(view).toUrl());
+      data.put("logUrl", GitilesView.log().copyFrom(view).toUrl());
+      data.put("blameUrl", GitilesView.blame().copyFrom(view).toUrl());
+    }
     data.put("navbarHtml", new MarkdownToHtml(view, cfg).toSoyHtml(nav));
     data.put("bodyHtml", new MarkdownToHtml(view, cfg)
         .setImageLoader(img)
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java
index 6687720..2721299 100644
--- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java
+++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java
@@ -308,7 +308,7 @@
       return url;
     }
     if (MarkdownUtil.isAbsolutePathToMarkdown(url)) {
-      return GitilesView.path().copyFrom(view).setPathPart(url).build().toUrl();
+      return GitilesView.doc().copyFrom(view).setPathPart(url).build().toUrl();
     }
     if (readme && !url.startsWith("../") && !url.startsWith("./")) {
       String dir = "";
diff --git a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Doc.soy b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Doc.soy
index 88558d9..2d11d97 100644
--- a/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Doc.soy
+++ b/gitiles-servlet/src/main/resources/com/google/gitiles/templates/Doc.soy
@@ -64,9 +64,9 @@
   <div class="footer-line">
     <div class="nav-aux">
       <ul>
-      <li><a href="{$sourceUrl}">{msg desc="text for the source link"}source{/msg}</a></li>
-      <li><a href="{$logUrl}">{msg desc="text for the log link"}log{/msg}</a></li>
-      <li><a href="{$blameUrl}">{msg desc="text for the blame link"}blame{/msg}</a></li>
+      {if $sourceUrl}<li><a href="{$sourceUrl}">{msg desc="text for the source link"}source{/msg}</a></li>{/if}
+      {if $logUrl}<li><a href="{$logUrl}">{msg desc="text for the log link"}log{/msg}</a></li>{/if}
+      {if $blameUrl}<li><a href="{$blameUrl}">{msg desc="text for the blame link"}blame{/msg}</a></li>{/if}
       </ul>
       <div class="gitiles-att">
       Powered by <a href="https://code.google.com/p/gitiles/">Gitiles</a>
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
index 9e6ddd8..9197c0c 100644
--- a/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
+++ b/gitiles-servlet/src/test/java/com/google/gitiles/GitilesViewTest.java
@@ -327,6 +327,25 @@
   }
 
   @Test
+  public void rootedDoc() throws Exception {
+    ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
+    GitilesView view = GitilesView.rootedDoc()
+        .copyFrom(HOST)
+        .setRevision(Revision.unpeeled("master", id))
+        .setPathPart("/docs/")
+        .build();
+
+    assertEquals("/b", view.getServletPath());
+    assertEquals(Type.ROOTED_DOC, view.getType());
+    assertEquals("host", view.getHostName());
+    assertEquals(id, view.getRevision().getId());
+    assertEquals("master", view.getRevision().getName());
+    assertEquals("docs", view.getPathPart());
+    assertTrue(HOST.getParameters().isEmpty());
+    assertEquals("/b/docs", view.toUrl());
+  }
+
+  @Test
   public void multiplePathComponents() throws Exception {
     ObjectId id = ObjectId.fromString("abcd1234abcd1234abcd1234abcd1234abcd1234");
     GitilesView view = GitilesView.path()