Add support for downloading archives with /+archive/foo.tar.gz Support all the archive formats supported upstream in JGit, with the exception of zip. Change-Id: I8cdec13882117f5b716e54479cff903d8b25a933
diff --git a/gitiles-servlet/pom.xml b/gitiles-servlet/pom.xml index 26642e0..4a41bb5 100644 --- a/gitiles-servlet/pom.xml +++ b/gitiles-servlet/pom.xml
@@ -54,6 +54,11 @@ <dependency> <groupId>org.eclipse.jgit</groupId> + <artifactId>org.eclipse.jgit.archive</artifactId> + </dependency> + + <dependency> + <groupId>org.eclipse.jgit</groupId> <artifactId>org.eclipse.jgit.junit</artifactId> <scope>test</scope> <exclusions>
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ArchiveServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/ArchiveServlet.java new file mode 100644 index 0000000..b7d49dd --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/ArchiveServlet.java
@@ -0,0 +1,126 @@ +// Copyright 2013 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 javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; +import static javax.servlet.http.HttpServletResponse.SC_OK; + +import com.google.common.collect.ImmutableMap; + +import org.eclipse.jgit.api.ArchiveCommand; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.archive.TarFormat; +import org.eclipse.jgit.archive.Tbz2Format; +import org.eclipse.jgit.archive.TgzFormat; +import org.eclipse.jgit.archive.TxzFormat; +import org.eclipse.jgit.errors.IncorrectObjectTypeException; +import org.eclipse.jgit.http.server.ServletUtils; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevWalk; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +public class ArchiveServlet extends BaseServlet { + private static final long serialVersionUID = 1L; + + private enum Format { + TAR("application/x-tar", new TarFormat()), + TGZ("application/x-gzip", new TgzFormat()), + TBZ2("application/x-bzip2", new Tbz2Format()), + TXZ("application/x-xz", new TxzFormat()); + // Zip is not supported because it may be interpreted by a Java plugin as a + // valid JAR file, whose code would have access to cookies on the domain. + + private final ArchiveCommand.Format<?> format; + private final String mimeType; + + private Format(String mimeType, ArchiveCommand.Format<?> format) { + this.format = format; + this.mimeType = mimeType; + ArchiveCommand.registerFormat(name(), format); + } + } + + private static final Map<String, Format> FORMATS_BY_EXTENSION; + + static { + ImmutableMap.Builder<String, Format> exts = ImmutableMap.builder(); + for (Format format : Format.values()) { + for (String ext : format.format.suffixes()) { + exts.put(ext, format); + } + } + FORMATS_BY_EXTENSION = exts.build(); + } + + static Set<String> validExtensions() { + return FORMATS_BY_EXTENSION.keySet(); + } + + public ArchiveServlet() { + super(null); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse res) + throws IOException, ServletException { + GitilesView view = ViewFilter.getView(req); + Revision rev = view.getRevision(); + Repository repo = ServletUtils.getRepository(req); + + // Check object type before starting the archive. If we just caught the + // exception from cmd.call() below, we wouldn't know whether it was because + // the input object is not a tree or something broke later. + RevWalk walk = new RevWalk(repo); + try { + walk.parseTree(rev.getId()); + } catch (IncorrectObjectTypeException e) { + res.sendError(SC_NOT_FOUND); + return; + } finally { + walk.release(); + } + + Format format = FORMATS_BY_EXTENSION.get(view.getExtension()); + String filename = getFilename(view, rev, view.getExtension()); + setDownloadHeaders(req, res, filename, format.mimeType); + res.setStatus(SC_OK); + + try { + new ArchiveCommand(repo) + .setFormat(format.name()) + .setTree(rev.getId()) + .setOutputStream(res.getOutputStream()) + .call(); + } catch (GitAPIException e) { + throw new IOException(e); + } + } + + private String getFilename(GitilesView view, Revision rev, String ext) { + return new StringBuilder() + .append(Paths.basename(view.getRepositoryName())) + .append('-') + .append(rev.getName()) + .append(ext) + .toString(); + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java index e344b70..12efb2c 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java
@@ -279,4 +279,11 @@ res.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*"); setCacheHeaders(req, res); } + + protected void setDownloadHeaders(HttpServletRequest req, HttpServletResponse res, + String filename, String contentType) { + res.setContentType(contentType); + res.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename); + setCacheHeaders(req, res); + } }
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 1955fff..cb2e459 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
@@ -242,6 +242,8 @@ return new LogServlet(renderer, linkifier()); case DESCRIBE: return new DescribeServlet(); + case ARCHIVE: + return new ArchiveServlet(); 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 1dc3b64..9121578 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
@@ -16,6 +16,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; import static com.google.gitiles.GitilesUrls.NAME_ESCAPER; import com.google.common.annotations.VisibleForTesting; @@ -34,6 +35,7 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import java.util.EnumSet; import java.util.List; import java.util.Map; @@ -48,6 +50,8 @@ * Construction happens in {@link ViewFilter}. */ public class GitilesView { + private static final String DEFAULT_ARCHIVE_EXTENSION = ".tar.gz"; + /** All the possible view types supported in the application. */ public static enum Type { HOST_INDEX, @@ -57,7 +61,8 @@ PATH, DIFF, LOG, - DESCRIBE; + DESCRIBE, + ARCHIVE; } /** Exception thrown when building a view that is invalid. */ @@ -80,6 +85,7 @@ private Revision revision = Revision.NULL; private Revision oldRevision = Revision.NULL; private String path; + private String extension; private String anchor; private Builder(Type type) { @@ -98,6 +104,7 @@ path = other.path; // Fallthrough. case REVISION: + case ARCHIVE: revision = other.revision; // Fallthrough. case DESCRIBE: @@ -108,6 +115,9 @@ default: break; } + if (type == Type.ARCHIVE) { + extension = other.extension; + } // Don't copy params. return this; } @@ -222,6 +232,22 @@ return path; } + public Builder setExtension(String extension) { + switch (type) { + default: + checkState(extension == null, "cannot set path on %s view", type); + // Fallthrough; + case ARCHIVE: + this.extension = extension; + break; + } + return this; + } + + public String getExtension() { + return extension; + } + public Builder putParam(String key, String value) { params.put(key, value); return this; @@ -280,9 +306,12 @@ case LOG: checkLog(); break; + case ARCHIVE: + checkArchive(); + break; } return new GitilesView(type, hostName, servletPath, repositoryName, revision, - oldRevision, path, params, anchor); + oldRevision, path, extension, params, anchor); } public String toUrl() { @@ -330,6 +359,10 @@ checkView(path != null, "missing path on %s view", type); checkRevision(); } + + private void checkArchive() { + checkRevision(); + } } public static Builder hostIndex() { @@ -364,6 +397,10 @@ return new Builder(Type.LOG); } + public static Builder archive() { + return new Builder(Type.ARCHIVE); + } + static String maybeTrimLeadingAndTrailingSlash(String str) { if (str.startsWith("/")) { str = str.substring(1); @@ -378,6 +415,7 @@ private final Revision revision; private final Revision oldRevision; private final String path; + private final String extension; private final ListMultimap<String, String> params; private final String anchor; @@ -388,6 +426,7 @@ Revision revision, Revision oldRevision, String path, + String extension, ListMultimap<String, String> params, String anchor) { this.type = type; @@ -397,6 +436,7 @@ this.revision = Objects.firstNonNull(revision, Revision.NULL); this.oldRevision = Objects.firstNonNull(oldRevision, Revision.NULL); this.path = path; + this.extension = extension; this.params = Multimaps.unmodifiableListMultimap(params); this.anchor = anchor; } @@ -445,6 +485,10 @@ return path; } + public String getExtension() { + return extension; + } + public ListMultimap<String, String> getParameters() { return params; } @@ -466,7 +510,8 @@ .add("repo", repositoryName) .add("rev", revision) .add("old", oldRevision) - .add("path", path); + .add("path", path) + .add("extension", extension); if (!params.isEmpty()) { b.add("params", params); } @@ -498,6 +543,10 @@ case REVISION: url.append(repositoryName).append("/+/").append(revision.getName()); break; + case ARCHIVE: + url.append(repositoryName).append("/+archive/").append(revision.getName()) + .append(Objects.firstNonNull(extension, DEFAULT_ARCHIVE_EXTENSION)); + break; case PATH: url.append(repositoryName).append("/+/").append(revision.getName()).append('/') .append(path); @@ -548,6 +597,8 @@ return getBreadcrumbs(null); } + private static final EnumSet<Type> NON_HTML_TYPES = EnumSet.of(Type.DESCRIBE, Type.ARCHIVE); + /** * @param hasSingleTree list of booleans, one per path entry in this view's * path excluding the leaf. True entries indicate the tree at that path @@ -558,8 +609,8 @@ * auto-diving into one-entry subtrees. */ public List<Map<String, String>> getBreadcrumbs(List<Boolean> hasSingleTree) { - checkArgument(type != Type.DESCRIBE, - "breadcrumbs for DESCRIBE view not supported"); + checkArgument(!NON_HTML_TYPES.contains(type), + "breadcrumbs for %s view not supported", type); checkArgument(type != Type.REFS || Strings.isNullOrEmpty(path), "breadcrumbs for REFS view with path not supported"); checkArgument(hasSingleTree == null || type == Type.PATH,
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Paths.java b/gitiles-servlet/src/main/java/com/google/gitiles/Paths.java index e4e66af..f69995b 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/Paths.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/Paths.java
@@ -14,6 +14,7 @@ package com.google.gitiles; +import com.google.common.base.CharMatcher; import com.google.common.base.Splitter; import com.google.common.base.Strings; import com.google.common.io.Files; @@ -22,7 +23,8 @@ /** Static utilities for dealing with pathnames. */ class Paths { - static final Splitter SPLITTER = Splitter.on('/'); + private static final CharMatcher MATCHER = CharMatcher.is('/'); + static final Splitter SPLITTER = Splitter.on(MATCHER); static String simplifyPathUpToRoot(String path, String root) { if (path.startsWith("/")) { @@ -48,6 +50,15 @@ return !result.equals(".") ? result : ""; } + static String basename(String path) { + path = MATCHER.trimTrailingFrom(path); + int slash = path.lastIndexOf('/'); + if (slash < 0) { + return path; + } + return path.substring(slash + 1); + } + private Paths() { } }
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 767b99c..5b373de 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
@@ -39,6 +39,7 @@ private static final String VIEW_ATTIRBUTE = ViewFilter.class.getName() + "/View"; + private static final String CMD_ARCHIVE = "+archive"; private static final String CMD_AUTO = "+"; private static final String CMD_DESCRIBE = "+describe"; private static final String CMD_DIFF = "+diff"; @@ -114,6 +115,8 @@ if (command.isEmpty()) { return parseNoCommand(req, repoName, path); + } else if (command.equals(CMD_ARCHIVE)) { + return parseArchiveCommand(req, repoName, path); } else if (command.equals(CMD_AUTO)) { return parseAutoCommand(req, repoName, path); } else if (command.equals(CMD_DESCRIBE)) { @@ -136,6 +139,29 @@ return GitilesView.repositoryIndex().setRepositoryName(repoName); } + private GitilesView.Builder parseArchiveCommand( + HttpServletRequest req, String repoName, String path) throws IOException { + String ext = null; + for (String e : ArchiveServlet.validExtensions()) { + if (path.endsWith(e)) { + path = path.substring(0, path.length() - e.length()); + ext = e; + break; + } + } + if (ext == null) { + return null; + } + RevisionParser.Result result = parseRevision(req, path); + if (result == null || result.getOldRevision() != null) { + return null; + } + return GitilesView.archive() + .setRepositoryName(repoName) + .setRevision(result.getRevision()) + .setExtension(ext); + } + private GitilesView.Builder parseAutoCommand( HttpServletRequest req, String repoName, String path) throws IOException { // Note: if you change the mapping for +, make sure to change
diff --git a/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java index 80d204a..0f00174 100644 --- a/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java +++ b/gitiles-servlet/src/test/java/com/google/gitiles/ViewFilterTest.java
@@ -380,6 +380,36 @@ assertEquals("", view.getPathPart()); } + public void testArchive() throws Exception { + RevCommit master = repo.branch("refs/heads/master").commit().create(); + repo.branch("refs/heads/branch").commit().create(); + GitilesView view; + + assertNull(getView("/repo/+archive")); + assertNull(getView("/repo/+archive/")); + assertNull(getView("/repo/+archive/master..branch")); + assertNull(getView("/repo/+archive/master.foo")); + assertNull(getView("/repo/+archive/master.zip")); + + view = getView("/repo/+archive/master.tar.gz"); + assertEquals(Type.ARCHIVE, view.getType()); + assertEquals("repo", view.getRepositoryName()); + assertEquals("master", view.getRevision().getName()); + assertEquals(master, view.getRevision().getId()); + assertEquals(Revision.NULL, view.getOldRevision()); + assertEquals(".tar.gz", view.getExtension()); + assertNull(view.getPathPart()); + + view = getView("/repo/+archive/master.tar.bz2"); + assertEquals(Type.ARCHIVE, view.getType()); + assertEquals("repo", view.getRepositoryName()); + assertEquals("master", view.getRevision().getName()); + assertEquals(master, view.getRevision().getId()); + assertEquals(Revision.NULL, view.getOldRevision()); + assertEquals(".tar.bz2", view.getExtension()); + assertNull(view.getPathPart()); + } + private GitilesView getView(String pathAndQuery) throws ServletException, IOException { final AtomicReference<GitilesView> view = Atomics.newReference(); HttpServlet testServlet = new HttpServlet() {
diff --git a/pom.xml b/pom.xml index 17ebc66..c1b4555 100644 --- a/pom.xml +++ b/pom.xml
@@ -70,6 +70,12 @@ <dependency> <groupId>org.eclipse.jgit</groupId> + <artifactId>org.eclipse.jgit.archive</artifactId> + <version>${jgitVersion}</version> + </dependency> + + <dependency> + <groupId>org.eclipse.jgit</groupId> <artifactId>org.eclipse.jgit.junit</artifactId> <version>${jgitVersion}</version> <exclusions>