Merge branch 'stable-2.10'

* stable-2.10:
  Update Gerrit-ApiVersion to 2.10-SNAPSHOT
  Fix position of image tooltip
  Add servlet to serve images
  Update Gerrit-ApiVersion to 2.9.1

Change-Id: I3a9a21765d98f60448808041bb2b691cc2b7f35b
diff --git a/src/main/java/com/googlesource/gerrit/plugins/imagare/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/imagare/HttpModule.java
index d72589c..daf5355 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/imagare/HttpModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/imagare/HttpModule.java
@@ -24,6 +24,9 @@
 
   @Override
   protected void configureServlets() {
+    serveRegex("^" + ImageServlet.PATH_PREFIX + "(.+)?$")
+        .with(ImageServlet.class);
+
     DynamicSet.bind(binder(), WebUiPlugin.class)
         .toInstance(new GwtPlugin("imagare"));
     DynamicSet.bind(binder(), WebUiPlugin.class)
diff --git a/src/main/java/com/googlesource/gerrit/plugins/imagare/ImageServlet.java b/src/main/java/com/googlesource/gerrit/plugins/imagare/ImageServlet.java
new file mode 100644
index 0000000..7396b9b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/imagare/ImageServlet.java
@@ -0,0 +1,293 @@
+// Copyright (C) 2014 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.googlesource.gerrit.plugins.imagare;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.hash.Hashing;
+import com.google.common.net.HttpHeaders;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.FileTypeRegistry;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.project.GetHead;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtexpui.server.CacheHeaders;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.errors.RevisionSyntaxException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.PathFilter;
+
+import eu.medsea.mimeutil.MimeType;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+public class ImageServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
+  public  static final String PATH_PREFIX = "/project/";
+
+  private final ProjectControl.Factory projectControlFactory;
+  private final ProjectCache projectCache;
+  private final Provider<GetHead> getHead;
+  private final GitRepositoryManager repoManager;
+  private final FileTypeRegistry fileTypeRegistry;
+
+  @Inject
+  ImageServlet(
+      ProjectControl.Factory projectControlFactory,
+      ProjectCache projectCache,
+      Provider<GetHead> getHead,
+      GitRepositoryManager repoManager,
+      FileTypeRegistry fileTypeRegistry) {
+    this.projectControlFactory = projectControlFactory;
+    this.projectCache = projectCache;
+    this.getHead = getHead;
+    this.repoManager = repoManager;
+    this.fileTypeRegistry = fileTypeRegistry;
+  }
+
+  @Override
+  public void service(HttpServletRequest req, HttpServletResponse res)
+      throws IOException {
+    if (!"GET".equals(req.getMethod()) && !"HEAD".equals(req.getMethod())) {
+      CacheHeaders.setNotCacheable(res);
+      res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
+      return;
+    }
+
+    ResourceKey key = ResourceKey.fromPath(req.getPathInfo());
+    ProjectState state = projectCache.get(key.project);
+    if (state == null || key.file == null) {
+      notFound(res);
+      return;
+    }
+
+    MimeType mimeType = fileTypeRegistry.getMimeType(key.file, null);
+    if (!("image".equals(mimeType.getMediaType())
+          && fileTypeRegistry.isSafeInline(mimeType))) {
+      notFound(res);
+      return;
+    }
+
+    try {
+      ProjectControl projectControl = projectControlFactory.validateFor(key.project);
+      String rev = key.revision;
+      if (rev == null || Constants.HEAD.equals(rev)) {
+        rev = getHead.get().apply(new ProjectResource(projectControl));
+      } else  {
+        if (!ObjectId.isId(rev)) {
+          if (!rev.startsWith(Constants.R_REFS)) {
+            rev = Constants.R_HEADS + rev;
+          }
+          if (!projectControl.controlForRef(rev).isVisible()) {
+            notFound(res);
+            return;
+          }
+        }
+      }
+      Repository repo = repoManager.openRepository(key.project);
+      try {
+        ObjectId revId =
+            repo.resolve(rev != null ? rev : Constants.HEAD);
+        if (revId == null) {
+          notFound(res);
+          return;
+        }
+
+        if (ObjectId.isId(rev)) {
+          RevWalk rw = new RevWalk(repo);
+          try {
+            RevCommit commit = rw.parseCommit(repo.resolve(rev));
+            if (!projectControl.canReadCommit(rw, commit)) {
+              notFound(res);
+              return;
+            }
+          } finally {
+            rw.release();
+          }
+        }
+
+        String eTag = null;
+        String receivedETag = req.getHeader(HttpHeaders.IF_NONE_MATCH);
+        if (receivedETag != null) {
+          eTag = computeETag(key.project, revId, key.file);
+          if (eTag.equals(receivedETag)) {
+            res.sendError(SC_NOT_MODIFIED);
+            return;
+          }
+        }
+
+        if (!"image".equals(mimeType.getMediaType())) {
+          notFound(res);
+          return;
+        }
+
+        RevWalk rw = new RevWalk(repo);
+        try {
+          RevCommit commit = rw.parseCommit(revId);
+          RevTree tree = commit.getTree();
+          TreeWalk tw = new TreeWalk(repo);
+          try {
+            tw.addTree(tree);
+            tw.setRecursive(true);
+            tw.setFilter(PathFilter.create(key.file));
+            if (!tw.next()) {
+              notFound(res);
+              return;
+            }
+            ObjectId objectId = tw.getObjectId(0);
+            ObjectLoader loader = repo.open(objectId);
+            byte[] content = loader.getBytes(Integer.MAX_VALUE);
+
+            mimeType = fileTypeRegistry.getMimeType(key.file, content);
+            if (!"image".equals(mimeType.getMediaType())
+                || !fileTypeRegistry.isSafeInline(mimeType)) {
+              notFound(res);
+              return;
+            }
+            res.setHeader(HttpHeaders.ETAG,
+                eTag != null
+                    ? eTag
+                    : computeETag(key.project, revId, key.file));
+            CacheHeaders.setCacheablePrivate(res, 7, TimeUnit.DAYS, false);
+            send(req, res, content, mimeType.toString(), commit.getCommitTime());
+            return;
+          } finally {
+            tw.release();
+          }
+        } catch (IOException e) {
+          notFound(res);
+          return;
+        } finally {
+          rw.release();
+        }
+      } finally {
+        repo.close();
+      }
+    } catch (RepositoryNotFoundException | NoSuchProjectException
+        | ResourceNotFoundException | AuthException | RevisionSyntaxException e) {
+      notFound(res);
+      return;
+    }
+  }
+
+  private static String computeETag(Project.NameKey project, ObjectId revId,
+      String file) {
+    return Hashing.md5().newHasher()
+        .putUnencodedChars(project.get())
+        .putUnencodedChars(revId.getName())
+        .putUnencodedChars(file)
+        .hash().toString();
+  }
+
+  private void send(HttpServletRequest req, HttpServletResponse res,
+      byte[] content, String contentType, long lastModified) throws IOException {
+    if (0 < lastModified) {
+      long ifModifiedSince = req.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE);
+      if (ifModifiedSince > 0 && ifModifiedSince == lastModified) {
+        res.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
+        return;
+      } else {
+        res.setDateHeader("Last-Modified", lastModified);
+      }
+    }
+    res.setContentType(contentType);
+    res.setCharacterEncoding(UTF_8.name());
+    res.setContentLength(content.length);
+    res.getOutputStream().write(content);
+  }
+
+  private static void notFound(HttpServletResponse res) throws IOException {
+    CacheHeaders.setNotCacheable(res);
+    res.sendError(HttpServletResponse.SC_NOT_FOUND);
+  }
+
+  private static class ResourceKey {
+    final Project.NameKey project;
+    final String file;
+    final String revision;
+
+    static ResourceKey fromPath(String path) {
+      String project;
+      String file = null;
+      String revision = null;
+
+      if (!path.startsWith(PATH_PREFIX)) {
+        // should not happen since this servlet is only registered to handle
+        // paths that start with this prefix
+        throw new IllegalStateException("path must start with '" + PATH_PREFIX + "'");
+      }
+      path = path.substring(PATH_PREFIX.length());
+
+      int i = path.indexOf('/');
+      if (i != -1 && i != path.length() - 1) {
+        project = IdString.fromUrl(path.substring(0, i)).get();
+        String rest = path.substring(i + 1);
+
+        if (rest.startsWith("rev/")) {
+          if (rest.length() > 4) {
+            rest = rest.substring(4);
+            i = rest.indexOf('/');
+            if (i != -1 && i != path.length() - 1) {
+              revision = IdString.fromUrl(rest.substring(0, i)).get();
+              file = IdString.fromUrl(rest.substring(i + 1)).get();
+            } else {
+              revision = IdString.fromUrl(rest).get();
+            }
+          }
+        } else {
+          file = IdString.fromUrl(rest).get();
+        }
+
+      } else {
+        project = IdString.fromUrl(CharMatcher.is('/').trimTrailingFrom(path)).get();
+      }
+
+      return new ResourceKey(project, file, revision);
+    }
+
+    private ResourceKey(String p, String f, String r) {
+      project = new Project.NameKey(p);
+      file = f;
+      revision = r;
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/imagare/PostImage.java b/src/main/java/com/googlesource/gerrit/plugins/imagare/PostImage.java
index ffc966a..de6d0da 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/imagare/PostImage.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/imagare/PostImage.java
@@ -15,6 +15,7 @@
 package com.googlesource.gerrit.plugins.imagare;
 
 import com.google.gerrit.common.ChangeHooks;
+import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -76,12 +77,14 @@
   private final PersonIdent myIdent;
   private final String canonicalWebUrl;
   private final Config cfg;
+  private final String pluginName;
 
   @Inject
   public PostImage(FileTypeRegistry registry, Provider<IdentifiedUser> self,
       GitRepositoryManager repoManager, GitReferenceUpdated referenceUpdated,
       ChangeHooks hooks, @GerritPersonIdent PersonIdent myIdent,
-      @CanonicalWebUrl String canonicalWebUrl, @GerritServerConfig Config cfg) {
+      @CanonicalWebUrl String canonicalWebUrl, @GerritServerConfig Config cfg,
+      @PluginName String pluginName) {
     this.registry = registry;
     this.imageDataPattern = Pattern.compile("data:([\\w/.-]+);([\\w]+),(.*)");
     this.self = self;
@@ -91,6 +94,7 @@
     this.myIdent = myIdent;
     this.canonicalWebUrl = canonicalWebUrl;
     this.cfg = cfg;
+    this.pluginName = pluginName;
   }
 
   @Override
@@ -238,9 +242,11 @@
     StringBuilder url = new StringBuilder();
     url.append(canonicalWebUrl);
     if (!canonicalWebUrl.endsWith("/")) {
-      url.append("src/");
+      url.append("/");
     }
-    url.append("src/");
+    url.append("plugins/");
+    url.append(IdString.fromDecoded(pluginName).encoded());
+    url.append("/project/");
     url.append(IdString.fromDecoded(project.get()).encoded());
     url.append("/rev/");
     url.append(IdString.fromDecoded(rev).encoded());
diff --git a/src/main/resources/static/imagare.js b/src/main/resources/static/imagare.js
index c23e5db..244350b 100644
--- a/src/main/resources/static/imagare.js
+++ b/src/main/resources/static/imagare.js
@@ -45,7 +45,7 @@
           l[i].onmouseover = function (evt) {
             var img = document.createElement('img');
             img.setAttribute('src', this.href);
-            img.setAttribute('style', 'border: 1px solid #B3B2B2; position: absolute; bottom: ' + (this.offsetHeight + 3) + 'px');
+            img.setAttribute('style', 'border: 1px solid #B3B2B2; position: absolute; top: ' + (this.offsetTop + this.offsetHeight) + 'px');
             this.parentNode.insertBefore(img, this);
             this.onmouseout = function (evt) {
               this.parentNode.removeChild(this.previousSibling);
@@ -56,7 +56,7 @@
     }
 
     function isImage(href) {
-      return href.match(window.location.hostname + '.*src/.*/rev/.*/.*\.(jpg|jpeg|png|gif|bmp|ico|svg|tif|tiff)')
+      return href.match(window.location.hostname + '.*project/.*/rev/.*/.*\.(jpg|jpeg|png|gif|bmp|ico|svg|tif|tiff)')
     }
 
     Gerrit.on('history', onHistory);