Gitiles is a simple browser for Git repositories It is based on JGit and uses Google Closure Templates as a templating language. Access to underlying repositories is based on a few simple interfaces; currently, there is only a simple disk-based implementation, but other implementations are possible. Features include viewing repositories by branch, shortlogs, showing individual files and diffs with syntax highlighting, with many more planned. The application itself is a standard Java servlet and is configured primarily via a git config format file. Deploying the WAR in any servlet container should be possible. In addition, a standalone server may be run with jetty-maven-plugin with `mvn package && mvn jetty:run`. Change-Id: I0ab8875b6c50f7df03b9a42b4a60923a4827bde7
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/AbstractHttpFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/AbstractHttpFilter.java new file mode 100644 index 0000000..092e12d --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/AbstractHttpFilter.java
@@ -0,0 +1,49 @@ +// Copyright 2012 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 java.io.IOException; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +abstract class AbstractHttpFilter implements Filter { + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { + doFilter((HttpServletRequest) req, (HttpServletResponse) res, chain); + } + + @Override + @SuppressWarnings("unused") // Allow subclasses to throw ServletException. + public void init(FilterConfig config) throws ServletException { + // Default implementation does nothing. + } + + @Override + public void destroy() { + // Default implementation does nothing. + } + + /** @see #doFilter(ServletRequest, ServletResponse, FilterChain) */ + public abstract void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws IOException, ServletException; +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java new file mode 100644 index 0000000..70dc14e --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/BaseServlet.java
@@ -0,0 +1,119 @@ +// Copyright 2012 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 com.google.common.base.Charsets; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +import com.google.common.net.HttpHeaders; + +import org.joda.time.Instant; + +import java.io.IOException; +import java.util.Map; + +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** Base servlet class for Gitiles servlets that serve Soy templates. */ +public abstract class BaseServlet extends HttpServlet { + private static final String DATA_ATTRIBUTE = BaseServlet.class.getName() + "/Data"; + + static void setNotCacheable(HttpServletResponse res) { + res.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, max-age=0, must-revalidate"); + res.setHeader(HttpHeaders.PRAGMA, "no-cache"); + res.setHeader(HttpHeaders.EXPIRES, "Fri, 01 Jan 1990 00:00:00 GMT"); + res.setDateHeader(HttpHeaders.DATE, new Instant().getMillis()); + } + + public static BaseServlet notFoundServlet() { + return new BaseServlet(null) { + @Override + public void service(HttpServletRequest req, HttpServletResponse res) { + res.setStatus(SC_NOT_FOUND); + } + }; + } + + public static Map<String, String> menuEntry(String text, String url) { + if (url != null) { + return ImmutableMap.of("text", text, "url", url); + } else { + return ImmutableMap.of("text", text); + } + } + + protected static Map<String, Object> getData(HttpServletRequest req) { + @SuppressWarnings("unchecked") + Map<String, Object> data = (Map<String, Object>) req.getAttribute(DATA_ATTRIBUTE); + if (data == null) { + data = Maps.newHashMap(); + req.setAttribute(DATA_ATTRIBUTE, data); + } + return data; + } + + protected final Renderer renderer; + + protected BaseServlet(Renderer renderer) { + this.renderer = renderer; + } + + /** + * Put a value into a request's Soy data map. + * <p> + * This method is intended to support a composition pattern whereby a + * {@link BaseServlet} is wrapped in a different {@link HttpServlet} that can + * update its data map. + * + * @param req in-progress request. + * @param key key. + * @param value Soy data value. + */ + public void put(HttpServletRequest req, String key, Object value) { + getData(req).put(key, value); + } + + protected void render(HttpServletRequest req, HttpServletResponse res, String templateName, + Map<String, ?> soyData) throws IOException { + try { + res.setContentType(FormatType.HTML.getMimeType()); + res.setCharacterEncoding(Charsets.UTF_8.name()); + setCacheHeaders(req, res); + + Map<String, Object> allData = getData(req); + allData.putAll(soyData); + GitilesView view = ViewFilter.getView(req); + if (!allData.containsKey("repositoryName") && view.getRepositoryName() != null) { + allData.put("repositoryName", view.getRepositoryName()); + } + if (!allData.containsKey("breadcrumbs")) { + allData.put("breadcrumbs", view.getBreadcrumbs()); + } + + res.setStatus(HttpServletResponse.SC_OK); + renderer.render(res, templateName, allData); + } finally { + req.removeAttribute(DATA_ATTRIBUTE); + } + } + + protected void setCacheHeaders(HttpServletRequest req, HttpServletResponse res) { + setNotCacheable(res); + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java new file mode 100644 index 0000000..6b57cbc --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/BlobSoyData.java
@@ -0,0 +1,109 @@ +// Copyright 2012 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.lib.Constants.OBJ_COMMIT; + +import com.google.common.collect.Maps; + +import org.eclipse.jgit.diff.RawText; +import org.eclipse.jgit.errors.LargeObjectException; +import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectLoader; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.util.RawParseUtils; + +import java.io.IOException; +import java.util.Map; + +/** Soy data converter for git blobs. */ +public class BlobSoyData { + /** + * Maximum number of bytes to load from a supposed text file for display. + * Files larger than this will be displayed as binary files, even if the + * contents was text. For example really big XML files may be above this limit + * and will get displayed as binary. + */ + private static final int MAX_FILE_SIZE = 10 << 20; + + private final GitilesView view; + private final RevWalk walk; + + public BlobSoyData(RevWalk walk, GitilesView view) { + this.view = view; + this.walk = walk; + } + + public Map<String, Object> toSoyData(ObjectId blobId) + throws MissingObjectException, IOException { + return toSoyData(null, blobId); + } + + public Map<String, Object> toSoyData(String path, ObjectId blobId) + throws MissingObjectException, IOException { + Map<String, Object> data = Maps.newHashMapWithExpectedSize(4); + data.put("sha", ObjectId.toString(blobId)); + + ObjectLoader loader = walk.getObjectReader().open(blobId, Constants.OBJ_BLOB); + String content; + try { + byte[] raw = loader.getCachedBytes(MAX_FILE_SIZE); + content = !RawText.isBinary(raw) ? RawParseUtils.decode(raw) : null; + } catch (LargeObjectException.OutOfMemory e) { + throw e; + } catch (LargeObjectException e) { + content = null; + } + + data.put("data", content); + if (content != null) { + data.put("lang", guessPrettifyLang(path, content)); + } else if (content == null) { + data.put("size", Long.toString(loader.getSize())); + } + if (path != null && view.getRevision().getPeeledType() == OBJ_COMMIT) { + data.put("logUrl", GitilesView.log().copyFrom(view).toUrl()); + } + return data; + } + + private static String guessPrettifyLang(String path, String content) { + if (content.startsWith("#!/bin/sh") || content.startsWith("#!/bin/bash")) { + return "sh"; + } else if (content.startsWith("#!/usr/bin/perl")) { + return "perl"; + } else if (content.startsWith("#!/usr/bin/python")) { + return "py"; + } else if (path == null) { + return null; + } + + int slash = path.lastIndexOf('/'); + int dot = path.lastIndexOf('.'); + String lang = ((0 < dot) && (slash < dot)) ? path.substring(dot + 1) : null; + if ("txt".equalsIgnoreCase(lang)) { + return null; + } else if ("mk".equalsIgnoreCase(lang)) { + return "sh"; + } else if ("Makefile".equalsIgnoreCase(path) + || ((0 < slash) && "Makefile".equalsIgnoreCase(path.substring(slash + 1)))) { + return "sh"; + } else { + return lang; + } + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/CommitSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/CommitSoyData.java new file mode 100644 index 0000000..9f8a97d --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/CommitSoyData.java
@@ -0,0 +1,287 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.template.soy.data.restricted.NullData; + +import org.eclipse.jgit.diff.DiffEntry; +import org.eclipse.jgit.diff.DiffEntry.ChangeType; +import org.eclipse.jgit.diff.DiffFormatter; +import org.eclipse.jgit.lib.AnyObjectId; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.AbstractTreeIterator; +import org.eclipse.jgit.treewalk.CanonicalTreeParser; +import org.eclipse.jgit.treewalk.EmptyTreeIterator; +import org.eclipse.jgit.util.GitDateFormatter; +import org.eclipse.jgit.util.GitDateFormatter.Format; +import org.eclipse.jgit.util.RelativeDateFormatter; +import org.eclipse.jgit.util.io.NullOutputStream; + +import java.io.IOException; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.annotation.Nullable; +import javax.servlet.http.HttpServletRequest; + +/** Soy data converter for git commits. */ +public class CommitSoyData { + /** Valid sets of keys to include in Soy data for commits. */ + public static enum KeySet { + DETAIL("author", "committer", "sha", "tree", "treeUrl", "parents", "message", "logUrl"), + DETAIL_DIFF_TREE(DETAIL, "diffTree"), + SHORTLOG("abbrevSha", "url", "shortMessage", "author", "branches", "tags"), + DEFAULT(DETAIL); + + private final Set<String> keys; + + private KeySet(String... keys) { + this.keys = ImmutableSet.copyOf(keys); + } + + private KeySet(KeySet other, String... keys) { + this.keys = ImmutableSet.<String> builder().addAll(other.keys).add(keys).build(); + } + } + + private final Linkifier linkifier; + private final HttpServletRequest req; + private final Repository repo; + private final RevWalk walk; + private final GitilesView view; + private final Map<AnyObjectId, Set<Ref>> refsById; + private final GitDateFormatter dateFormatter; + + // TODO(dborowitz): This constructor is getting a bit ridiculous. + public CommitSoyData(@Nullable Linkifier linkifier, HttpServletRequest req, Repository repo, + RevWalk walk, GitilesView view) { + this(linkifier, req, repo, walk, view, null); + } + + public CommitSoyData(@Nullable Linkifier linkifier, HttpServletRequest req, Repository repo, + RevWalk walk, GitilesView view, @Nullable Map<AnyObjectId, Set<Ref>> refsById) { + this.linkifier = linkifier; + this.req = req; + this.repo = repo; + this.walk = walk; + this.view = view; + this.refsById = refsById; + this.dateFormatter = new GitDateFormatter(Format.DEFAULT); + } + + public Map<String, Object> toSoyData(RevCommit commit, KeySet keys) throws IOException { + Map<String, Object> data = Maps.newHashMapWithExpectedSize(KeySet.DEFAULT.keys.size()); + if (keys.keys.contains("author")) { + data.put("author", toSoyData(commit.getAuthorIdent(), dateFormatter)); + } + if (keys.keys.contains("committer")) { + data.put("committer", toSoyData(commit.getCommitterIdent(), dateFormatter)); + } + if (keys.keys.contains("sha")) { + data.put("sha", ObjectId.toString(commit)); + } + if (keys.keys.contains("abbrevSha")) { + ObjectReader reader = repo.getObjectDatabase().newReader(); + try { + data.put("abbrevSha", reader.abbreviate(commit).name()); + } finally { + reader.release(); + } + } + if (keys.keys.contains("url")) { + data.put("url", GitilesView.revision() + .copyFrom(view) + .setRevision(commit) + .toUrl()); + } + if (keys.keys.contains("logUrl")) { + Revision rev = view.getRevision(); + GitilesView.Builder logView = GitilesView.log() + .copyFrom(view) + .setRevision(rev.getId().equals(commit) ? rev.getName() : commit.name(), commit) + .setOldRevision(Revision.NULL) + .setTreePath(null); + data.put("logUrl", logView.toUrl()); + } + if (keys.keys.contains("tree")) { + data.put("tree", ObjectId.toString(commit.getTree())); + } + if (keys.keys.contains("treeUrl")) { + data.put("treeUrl", GitilesView.path().copyFrom(view).setTreePath("/").toUrl()); + } + if (keys.keys.contains("parents")) { + data.put("parents", toSoyData(view, commit.getParents())); + } + if (keys.keys.contains("shortMessage")) { + data.put("shortMessage", commit.getShortMessage()); + } + if (keys.keys.contains("branches")) { + data.put("branches", getRefsById(commit, Constants.R_HEADS)); + } + if (keys.keys.contains("tags")) { + data.put("tags", getRefsById(commit, Constants.R_TAGS)); + } + if (keys.keys.contains("message")) { + if (linkifier != null) { + data.put("message", linkifier.linkify(req, commit.getFullMessage())); + } else { + data.put("message", commit.getFullMessage()); + } + } + if (keys.keys.contains("diffTree")) { + data.put("diffTree", computeDiffTree(commit)); + } + checkState(keys.keys.size() == data.size(), "bad commit data keys: %s != %s", keys.keys, + data.keySet()); + return ImmutableMap.copyOf(data); + } + + public Map<String, Object> toSoyData(RevCommit commit) throws IOException { + return toSoyData(commit, KeySet.DEFAULT); + } + + // TODO(dborowitz): Extract this. + static Map<String, String> toSoyData(PersonIdent ident, GitDateFormatter dateFormatter) { + return ImmutableMap.of( + "name", ident.getName(), + "email", ident.getEmailAddress(), + "time", dateFormatter.formatDate(ident), + // TODO(dborowitz): Switch from relative to absolute at some threshold. + "relativeTime", RelativeDateFormatter.format(ident.getWhen())); + } + + private List<Map<String, String>> toSoyData(GitilesView view, RevCommit[] parents) { + List<Map<String, String>> result = Lists.newArrayListWithCapacity(parents.length); + int i = 1; + // TODO(dborowitz): Render something slightly different when we're actively + // viewing a diff against one of the parents. + for (RevCommit parent : parents) { + String name = parent.name(); + GitilesView.Builder diff = GitilesView.diff().copyFrom(view).setTreePath(""); + String parentName; + if (parents.length == 1) { + parentName = view.getRevision().getName() + "^"; + } else { + parentName = view.getRevision().getName() + "^" + (i++); + } + result.add(ImmutableMap.of( + "sha", name, + "url", GitilesView.revision() + .copyFrom(view) + .setRevision(parentName, parent) + .toUrl(), + "diffUrl", diff.setOldRevision(parentName, parent).toUrl())); + } + return result; + } + + private AbstractTreeIterator getTreeIterator(RevWalk walk, RevCommit commit) throws IOException { + CanonicalTreeParser p = new CanonicalTreeParser(); + p.reset(walk.getObjectReader(), walk.parseTree(walk.parseCommit(commit).getTree())); + return p; + } + + private Object computeDiffTree(RevCommit commit) throws IOException { + AbstractTreeIterator oldTree; + GitilesView.Builder diffUrl = GitilesView.diff().copyFrom(view) + .setTreePath(""); + switch (commit.getParentCount()) { + case 0: + oldTree = new EmptyTreeIterator(); + diffUrl.setOldRevision(Revision.NULL); + break; + case 1: + oldTree = getTreeIterator(walk, commit.getParent(0)); + diffUrl.setOldRevision(view.getRevision().getName() + "^", commit.getParent(0)); + break; + default: + // TODO(dborowitz): handle merges + return NullData.INSTANCE; + } + AbstractTreeIterator newTree = getTreeIterator(walk, commit); + + DiffFormatter diff = new DiffFormatter(NullOutputStream.INSTANCE); + try { + diff.setRepository(repo); + diff.setDetectRenames(true); + + List<Object> result = Lists.newArrayList(); + for (DiffEntry e : diff.scan(oldTree, newTree)) { + Map<String, Object> entry = Maps.newHashMapWithExpectedSize(5); + entry.put("path", e.getNewPath()); + entry.put("url", GitilesView.path() + .copyFrom(view) + .setTreePath(e.getNewPath()) + .toUrl()); + entry.put("diffUrl", diffUrl.setAnchor("F" + result.size()).toUrl()); + entry.put("changeType", e.getChangeType().toString()); + if (e.getChangeType() == ChangeType.COPY || e.getChangeType() == ChangeType.RENAME) { + entry.put("oldPath", e.getOldPath()); + } + result.add(entry); + } + return result; + } finally { + diff.release(); + } + } + + private static final Comparator<Map<String, String>> NAME_COMPARATOR = + new Comparator<Map<String, String>>() { + @Override + public int compare(Map<String, String> o1, Map<String, String> o2) { + return o1.get("name").compareTo(o2.get("name")); + } + }; + + private List<Map<String, String>> getRefsById(ObjectId id, String prefix) { + checkNotNull(refsById, "must pass in ID to ref map to look up refs by ID"); + Set<Ref> refs = refsById.get(id); + if (refs == null) { + return ImmutableList.of(); + } + List<Map<String, String>> result = Lists.newArrayListWithCapacity(refs.size()); + for (Ref ref : refs) { + if (ref.getName().startsWith(prefix)) { + result.add(ImmutableMap.of( + "name", ref.getName().substring(prefix.length()), + "url", GitilesView.revision() + .copyFrom(view) + .setRevision(Revision.unpeeled(ref.getName(), ref.getObjectId())) + .toUrl())); + } + } + Collections.sort(result, NAME_COMPARATOR); + return result; + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ConfigUtil.java b/gitiles-servlet/src/main/java/com/google/gitiles/ConfigUtil.java new file mode 100644 index 0000000..4f8733b --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/ConfigUtil.java
@@ -0,0 +1,141 @@ +// Copyright 2012 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 com.google.common.base.Predicates; +import com.google.common.cache.CacheBuilder; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; + +import org.eclipse.jgit.lib.Config; +import org.joda.time.Duration; + +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Utilities for working with {@link Config} objects. */ +public class ConfigUtil { + /** + * Read a duration value from the configuration. + * <p> + * Durations can be written as expressions, for example {@code "1 s"} or + * {@code "5 days"}. If units are not specified, milliseconds are assumed. + * + * @param config JGit config object. + * @param section section to read, e.g. "google" + * @param subsection subsection to read, e.g. "bigtable" + * @param name variable to read, e.g. "deadline". + * @param defaultValue value to use when the value is not assigned. + * @return a standard duration representing the time read, or defaultValue. + */ + public static Duration getDuration(Config config, String section, String subsection, String name, + Duration defaultValue) { + String valStr = config.getString(section, subsection, name); + if (valStr == null) { + return defaultValue; + } + + valStr = valStr.trim(); + if (valStr.length() == 0) { + return defaultValue; + } + + Matcher m = matcher("^([1-9][0-9]*(?:\\.[0-9]*)?)\\s*(.*)$", valStr); + if (!m.matches()) { + String key = section + (subsection != null ? "." + subsection : "") + "." + name; + throw new IllegalStateException("Not time unit: " + key + " = " + valStr); + } + + String digits = m.group(1); + String unitName = m.group(2).trim(); + + TimeUnit unit; + if ("".equals(unitName)) { + unit = TimeUnit.MILLISECONDS; + } else if (anyOf(unitName, "ms", "millis", "millisecond", "milliseconds")) { + unit = TimeUnit.MILLISECONDS; + } else if (anyOf(unitName, "s", "sec", "second", "seconds")) { + unit = TimeUnit.SECONDS; + } else if (anyOf(unitName, "m", "min", "minute", "minutes")) { + unit = TimeUnit.MINUTES; + } else if (anyOf(unitName, "h", "hr", "hour", "hours")) { + unit = TimeUnit.HOURS; + } else if (anyOf(unitName, "d", "day", "days")) { + unit = TimeUnit.DAYS; + } else { + String key = section + (subsection != null ? "." + subsection : "") + "." + name; + throw new IllegalStateException("Not time unit: " + key + " = " + valStr); + } + + try { + if (digits.indexOf('.') == -1) { + long val = Long.parseLong(digits); + return new Duration(val * TimeUnit.MILLISECONDS.convert(1, unit)); + } else { + double val = Double.parseDouble(digits); + return new Duration((long) (val * TimeUnit.MILLISECONDS.convert(1, unit))); + } + } catch (NumberFormatException nfe) { + String key = section + (subsection != null ? "." + subsection : "") + "." + name; + throw new IllegalStateException("Not time unit: " + key + " = " + valStr, nfe); + } + } + + /** + * Get a {@link CacheBuilder} from a config. + * + * @param config JGit config object. + * @param name name of the cache subsection under the "cache" section. + * @return a new cache builder. + */ + public static CacheBuilder<Object, Object> getCacheBuilder(Config config, String name) { + CacheBuilder<Object, Object> b = CacheBuilder.newBuilder(); + try { + if (config.getString("cache", name, "maximumWeight") != null) { + b.maximumWeight(config.getLong("cache", name, "maximumWeight", 20 << 20)); + } + if (config.getString("cache", name, "maximumSize") != null) { + b.maximumSize(config.getLong("cache", name, "maximumSize", 16384)); + } + Duration expireAfterWrite = getDuration(config, "cache", name, "expireAfterWrite", null); + if (expireAfterWrite != null) { + b.expireAfterWrite(expireAfterWrite.getMillis(), TimeUnit.MILLISECONDS); + } + Duration expireAfterAccess = getDuration(config, "cache", name, "expireAfterAccess", null); + if (expireAfterAccess != null) { + b.expireAfterAccess(expireAfterAccess.getMillis(), TimeUnit.MILLISECONDS); + } + // Add other methods as needed. + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Error getting CacheBuilder for " + name, e); + } catch (IllegalStateException e) { + throw new IllegalStateException("Error getting CacheBuilder for " + name, e); + } + return b; + } + + private static Matcher matcher(String pattern, String valStr) { + return Pattern.compile(pattern).matcher(valStr); + } + + private static boolean anyOf(String a, String... cases) { + return Iterables.any(ImmutableList.copyOf(cases), + Predicates.equalTo(a.toLowerCase())); + } + + private ConfigUtil() { + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DebugRenderer.java b/gitiles-servlet/src/main/java/com/google/gitiles/DebugRenderer.java new file mode 100644 index 0000000..e56518a --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/DebugRenderer.java
@@ -0,0 +1,57 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkState; + +import com.google.common.base.Function; +import com.google.common.collect.ImmutableMap; +import com.google.template.soy.SoyFileSet; +import com.google.template.soy.tofu.SoyTofu; + +import java.io.File; +import java.net.URISyntaxException; +import java.net.URL; + +/** Renderer that reloads Soy templates from the filesystem on every request. */ +public class DebugRenderer extends Renderer { + public DebugRenderer(String staticPrefix, String customTemplatesFilename, + final String soyTemplatesRoot) { + super( + new Function<String, URL>() { + @Override + public URL apply(String name) { + return toFileURL(soyTemplatesRoot + File.separator + name); + } + }, + ImmutableMap.<String, String> of(), staticPrefix, + toFileURL(customTemplatesFilename)); + } + + @Override + protected SoyTofu getTofu() { + SoyFileSet.Builder builder = new SoyFileSet.Builder() + .setCompileTimeGlobals(globals); + for (URL template : templates) { + try { + checkState(new File(template.toURI()).exists(), "Missing Soy template %s", template); + } catch (URISyntaxException e) { + throw new IllegalStateException(e); + } + builder.add(template); + } + return builder.build().compileToTofu(); + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DefaultAccess.java b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultAccess.java new file mode 100644 index 0000000..5b6d822 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultAccess.java
@@ -0,0 +1,239 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.Queues; + +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.errors.RepositoryNotFoundException; +import org.eclipse.jgit.http.server.ServletUtils; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.lib.StoredConfig; +import org.eclipse.jgit.transport.resolver.FileResolver; +import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException; +import org.eclipse.jgit.util.IO; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; + +import javax.servlet.http.HttpServletRequest; + +/** + * Default implementation of {@link GitilesAccess} with local repositories. + * <p> + * Repositories are scanned on-demand under the given path, configured by + * default from {@code gitiles.basePath}. There is no access control beyond what + * user the JVM is running under. + */ +public class DefaultAccess implements GitilesAccess { + private static final String ANONYMOUS_USER_KEY = "anonymous user"; + + public static class Factory implements GitilesAccess.Factory { + private final File basePath; + private final String canonicalBasePath; + private final String baseGitUrl; + private final FileResolver<HttpServletRequest> resolver; + + Factory(File basePath, String baseGitUrl, FileResolver<HttpServletRequest> resolver) + throws IOException { + this.basePath = checkNotNull(basePath, "basePath"); + this.baseGitUrl = checkNotNull(baseGitUrl, "baseGitUrl"); + this.resolver = checkNotNull(resolver, "resolver"); + this.canonicalBasePath = basePath.getCanonicalPath(); + } + + @Override + public GitilesAccess forRequest(HttpServletRequest req) { + String path = req.getPathInfo(); + String repositoryPath; + if (path == null || path == "/") { + repositoryPath = null; + } else { + int slashPlus = path.indexOf("/+/"); + if (slashPlus >= 0) { + repositoryPath = path.substring(0, slashPlus); + } else if (path.endsWith("/+")) { + repositoryPath = path.substring(0, path.length() - 2); + } else { + repositoryPath = path; + } + } + return newAccess(basePath, canonicalBasePath, baseGitUrl, resolver, req); + } + + protected DefaultAccess newAccess(File basePath, String canonicalBasePath, String baseGitUrl, + FileResolver<HttpServletRequest> resolver, HttpServletRequest req) { + return new DefaultAccess(basePath, canonicalBasePath, baseGitUrl, resolver, req); + } + } + + protected final File basePath; + protected final String canonicalBasePath; + protected final String baseGitUrl; + protected final FileResolver<HttpServletRequest> resolver; + protected final HttpServletRequest req; + + protected DefaultAccess(File basePath, String canonicalBasePath, String baseGitUrl, + FileResolver<HttpServletRequest> resolver, HttpServletRequest req) { + this.basePath = checkNotNull(basePath, "basePath"); + this.canonicalBasePath = checkNotNull(canonicalBasePath, "canonicalBasePath"); + this.baseGitUrl = checkNotNull(baseGitUrl, "baseGitUrl"); + this.resolver = checkNotNull(resolver, "resolver"); + this.req = checkNotNull(req, "req"); + } + + @Override + public Map<String, RepositoryDescription> listRepositories(Set<String> branches) + throws IOException { + Map<String, RepositoryDescription> repos = Maps.newTreeMap(); + for (Repository repo : scanRepositories(basePath, req)) { + repos.put(getRepositoryName(repo), buildDescription(repo, branches)); + repo.close(); + } + return repos; + } + + @Override + public Object getUserKey() { + // Always return the same anonymous user key (effectively running with the + // same user permissions as the JVM). Subclasses may override this behavior. + return ANONYMOUS_USER_KEY; + } + + @Override + public String getRepositoryName() { + return getRepositoryName(ServletUtils.getRepository(req)); + } + + @Override + public RepositoryDescription getRepositoryDescription() throws IOException { + return buildDescription(ServletUtils.getRepository(req), Collections.<String> emptySet()); + } + + private String getRepositoryName(Repository repo) { + String path = getRelativePath(repo); + if (repo.isBare() && path.endsWith(".git")) { + path = path.substring(0, path.length() - 4); + } + return path; + } + + private String getRelativePath(Repository repo) { + String path = repo.isBare() ? repo.getDirectory().getPath() : repo.getDirectory().getParent(); + if (repo.isBare()) { + path = repo.getDirectory().getPath(); + if (path.endsWith(".git")) { + path = path.substring(0, path.length() - 4); + } + } else { + path = repo.getDirectory().getParent(); + } + return getRelativePath(path); + } + + private String getRelativePath(String path) { + String base = basePath.getPath(); + if (path.startsWith(base)) { + return path.substring(base.length() + 1); + } + if (path.startsWith(canonicalBasePath)) { + return path.substring(canonicalBasePath.length() + 1); + } + throw new IllegalStateException(String.format( + "Repository path %s is outside base path %s", path, base)); + } + + private String loadDescriptionText(Repository repo) throws IOException { + String desc = null; + StoredConfig config = repo.getConfig(); + IOException configError = null; + try { + config.load(); + desc = config.getString("gitweb", null, "description"); + } catch (ConfigInvalidException e) { + configError = new IOException(e); + } + if (desc == null) { + File descFile = new File(repo.getDirectory(), "description"); + if (descFile.exists()) { + desc = new String(IO.readFully(descFile)); + } else if (configError != null) { + throw configError; + } + } + return desc; + } + + private RepositoryDescription buildDescription(Repository repo, Set<String> branches) + throws IOException { + RepositoryDescription desc = new RepositoryDescription(); + desc.name = getRepositoryName(repo); + desc.cloneUrl = baseGitUrl + getRelativePath(repo); + desc.description = loadDescriptionText(repo); + if (!branches.isEmpty()) { + desc.branches = Maps.newLinkedHashMap(); + for (String name : branches) { + Ref ref = repo.getRef(normalizeRefName(name)); + if ((ref != null) && (ref.getObjectId() != null)) { + desc.branches.put(name, ref.getObjectId().name()); + } + } + } + return desc; + } + + private static String normalizeRefName(String name) { + if (name.startsWith("refs/")) { + return name; + } + return "refs/heads/" + name; + } + + private Collection<Repository> scanRepositories(final File basePath, final HttpServletRequest req) throws IOException { + List<Repository> repos = Lists.newArrayList(); + Queue<File> todo = Queues.newArrayDeque(); + File[] baseFiles = basePath.listFiles(); + if (baseFiles == null) { + throw new IOException("base path is not a directory: " + basePath.getPath()); + } + todo.addAll(Arrays.asList(baseFiles)); + while (!todo.isEmpty()) { + File file = todo.remove(); + try { + repos.add(resolver.open(req, getRelativePath(file.getPath()))); + } catch (RepositoryNotFoundException e) { + File[] children = file.listFiles(); + if (children != null) { + todo.addAll(Arrays.asList(children)); + } + } catch (ServiceNotEnabledException e) { + throw new IOException(e); + } + } + return repos; + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DefaultRenderer.java b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultRenderer.java new file mode 100644 index 0000000..f4bd1fb --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultRenderer.java
@@ -0,0 +1,59 @@ +// Copyright 2012 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 com.google.common.base.Function; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.Resources; +import com.google.template.soy.SoyFileSet; +import com.google.template.soy.tofu.SoyTofu; + +import java.net.URL; +import java.util.Map; + +/** Renderer that precompiles Soy and uses static precompiled CSS. */ +public class DefaultRenderer extends Renderer { + private final SoyTofu tofu; + + DefaultRenderer() { + this("", null); + } + + public DefaultRenderer(String staticPrefix, URL customTemplates) { + this(ImmutableMap.<String, String> of(), staticPrefix, customTemplates); + } + + public DefaultRenderer(Map<String, String> globals, String staticPrefix, URL customTemplates) { + super( + new Function<String, URL>() { + @Override + public URL apply(String name) { + return Resources.getResource(Renderer.class, "templates/" + name); + } + }, + globals, staticPrefix, customTemplates); + SoyFileSet.Builder builder = new SoyFileSet.Builder() + .setCompileTimeGlobals(this.globals); + for (URL template : templates) { + builder.add(template); + } + tofu = builder.build().compileToTofu(); + } + + @Override + protected SoyTofu getTofu() { + return tofu; + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DefaultUrls.java b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultUrls.java new file mode 100644 index 0000000..6495b5d --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/DefaultUrls.java
@@ -0,0 +1,60 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkNotNull; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +import javax.servlet.http.HttpServletRequest; + +/** + * Default implementation of {@link GitilesUrls}. + * <p> + * This implementation uses statically-configured defaults, and thus assumes + * that the servlet is running a single virtual host. + */ +class DefaultUrls implements GitilesUrls { + private final String canonicalHostName; + private final String baseGitUrl; + private final String baseGerritUrl; + + DefaultUrls(String canonicalHostName, String baseGitUrl, String baseGerritUrl) + throws UnknownHostException { + if (canonicalHostName != null) { + this.canonicalHostName = canonicalHostName; + } else { + this.canonicalHostName = InetAddress.getLocalHost().getCanonicalHostName(); + } + this.baseGitUrl = checkNotNull(baseGitUrl, "baseGitUrl"); + this.baseGerritUrl = checkNotNull(baseGerritUrl, "baseGerritUrl"); + } + + @Override + public String getHostName(HttpServletRequest req) { + return canonicalHostName; + } + + @Override + public String getBaseGitUrl(HttpServletRequest req) { + return baseGitUrl; + } + + @Override + public String getBaseGerritUrl(HttpServletRequest req) { + return baseGerritUrl; + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/DiffServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/DiffServlet.java new file mode 100644 index 0000000..2408cd7 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/DiffServlet.java
@@ -0,0 +1,162 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkNotNull; +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; + +import com.google.common.base.Charsets; + +import org.eclipse.jgit.diff.DiffFormatter; +import org.eclipse.jgit.errors.IncorrectObjectTypeException; +import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.http.server.ServletUtils; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.AbstractTreeIterator; +import org.eclipse.jgit.treewalk.CanonicalTreeParser; +import org.eclipse.jgit.treewalk.EmptyTreeIterator; +import org.eclipse.jgit.treewalk.filter.PathFilter; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** Serves an HTML page with all the diffs for a commit. */ +public class DiffServlet extends BaseServlet { + private static final String PLACEHOLDER = "id=\"DIFF_OUTPUT_BLOCK\""; + + private final Linkifier linkifier; + + public DiffServlet(Renderer renderer, Linkifier linkifier) { + super(renderer); + this.linkifier = checkNotNull(linkifier, "linkifier"); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { + GitilesView view = ViewFilter.getView(req); + Repository repo = ServletUtils.getRepository(req); + + RevWalk walk = new RevWalk(repo); + try { + boolean showCommit; + AbstractTreeIterator oldTree; + AbstractTreeIterator newTree; + try { + // If we are viewing the diff between a commit and one of its parents, + // include the commit detail in the rendered page. + showCommit = isParentOf(walk, view.getOldRevision(), view.getRevision()); + oldTree = getTreeIterator(walk, view.getOldRevision().getId()); + newTree = getTreeIterator(walk, view.getRevision().getId()); + } catch (MissingObjectException e) { + res.setStatus(SC_NOT_FOUND); + return; + } catch (IncorrectObjectTypeException e) { + res.setStatus(SC_NOT_FOUND); + return; + } + + Map<String, Object> data = getData(req); + data.put("title", "Diff - " + view.getRevisionRange()); + if (showCommit) { + data.put("commit", new CommitSoyData(linkifier, req, repo, walk, view) + .toSoyData(walk.parseCommit(view.getRevision().getId()))); + } + if (!data.containsKey("repositoryName") && (view.getRepositoryName() != null)) { + data.put("repositoryName", view.getRepositoryName()); + } + if (!data.containsKey("breadcrumbs")) { + data.put("breadcrumbs", view.getBreadcrumbs()); + } + + String[] html = renderAndSplit(data); + res.setStatus(HttpServletResponse.SC_OK); + res.setContentType(FormatType.HTML.getMimeType()); + res.setCharacterEncoding(Charsets.UTF_8.name()); + setCacheHeaders(req, res); + + OutputStream out = res.getOutputStream(); + try { + out.write(html[0].getBytes(Charsets.UTF_8)); + formatHtmlDiff(out, repo, walk, oldTree, newTree, view.getTreePath()); + out.write(html[1].getBytes(Charsets.UTF_8)); + } finally { + out.close(); + } + } finally { + walk.release(); + } + } + + private static boolean isParentOf(RevWalk walk, Revision oldRevision, Revision newRevision) + throws MissingObjectException, IncorrectObjectTypeException, IOException { + RevCommit newCommit = walk.parseCommit(newRevision.getId()); + if (newCommit.getParentCount() > 0) { + return Arrays.asList(newCommit.getParents()).contains(oldRevision.getId()); + } else { + return oldRevision == Revision.NULL; + } + } + + private String[] renderAndSplit(Map<String, Object> data) { + String html = renderer.newRenderer("gitiles.diffDetail") + .setData(data) + .render(); + int id = html.indexOf(PLACEHOLDER); + if (id < 0) { + throw new IllegalStateException("Template must contain " + PLACEHOLDER); + } + + int lt = html.lastIndexOf('<', id); + int gt = html.indexOf('>', id + PLACEHOLDER.length()); + return new String[] {html.substring(0, lt), html.substring(gt + 1)}; + } + + private void formatHtmlDiff(OutputStream out, + Repository repo, RevWalk walk, + AbstractTreeIterator oldTree, AbstractTreeIterator newTree, + String path) + throws IOException { + DiffFormatter diff = new HtmlDiffFormatter(renderer, out); + try { + if (!path.equals("")) { + diff.setPathFilter(PathFilter.create(path)); + } + diff.setRepository(repo); + diff.setDetectRenames(true); + diff.format(oldTree, newTree); + } finally { + diff.release(); + } + } + + private static AbstractTreeIterator getTreeIterator(RevWalk walk, ObjectId id) + throws IOException { + if (!id.equals(ObjectId.zeroId())) { + CanonicalTreeParser p = new CanonicalTreeParser(); + p.reset(walk.getObjectReader(), walk.parseTree(id)); + return p; + } else { + return new EmptyTreeIterator(); + } + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/FormatType.java b/gitiles-servlet/src/main/java/com/google/gitiles/FormatType.java new file mode 100644 index 0000000..9417a4c --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/FormatType.java
@@ -0,0 +1,76 @@ +// Copyright 2012 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 com.google.common.base.Strings; +import com.google.common.net.HttpHeaders; + +import javax.servlet.http.HttpServletRequest; + +/** Type of formatting to use in the response to the client. */ +public enum FormatType { + HTML("text/html"), + TEXT("text/plain"), + JSON("application/json"), + DEFAULT("*/*"); + + private static final String FORMAT_TYPE_ATTRIBUTE = FormatType.class.getName(); + + public static FormatType getFormatType(HttpServletRequest req) { + FormatType result = (FormatType) req.getAttribute(FORMAT_TYPE_ATTRIBUTE); + if (result != null) { + return result; + } + + String format = req.getParameter("format"); + if (format != null) { + for (FormatType type : FormatType.values()) { + if (format.equalsIgnoreCase(type.name())) { + return set(req, type); + } + } + throw new IllegalArgumentException("Invalid format " + format); + } + + String accept = req.getHeader(HttpHeaders.ACCEPT); + if (Strings.isNullOrEmpty(accept)) { + return set(req, DEFAULT); + } + + for (String p : accept.split("[ ,;][ ,;]*")) { + for (FormatType type : FormatType.values()) { + if (p.equals(type.mimeType)) { + return set(req, type != HTML ? type : DEFAULT); + } + } + } + return set(req, DEFAULT); + } + + private static FormatType set(HttpServletRequest req, FormatType format) { + req.setAttribute(FORMAT_TYPE_ATTRIBUTE, format); + return format; + } + + private final String mimeType; + + private FormatType(String mimeType) { + this.mimeType = mimeType; + } + + public String getMimeType() { + return mimeType; + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesAccess.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesAccess.java new file mode 100644 index 0000000..648d709 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesAccess.java
@@ -0,0 +1,68 @@ +// Copyright 2012 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 org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException; +import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; + +import javax.servlet.http.HttpServletRequest; + +/** + * Git storage interface for Gitiles. + * <p> + * Each instance is associated with a single end-user request, which implicitly + * includes information about the host and repository. + */ +public interface GitilesAccess { + /** Factory for per-request access. */ + public interface Factory { + public GitilesAccess forRequest(HttpServletRequest req); + } + + /** + * List repositories on the host. + * + * @param branches branches to list along with each repository. + * @return map of repository names to descriptions. + * @throws ServiceNotEnabledException to trigger an HTTP 403 Forbidden + * (matching behavior in {@link org.eclipse.jgit.http.server.RepositoryFilter}). + * @throws ServiceNotAuthorizedException to trigger an HTTP 401 Unauthorized + * (matching behavior in {@link org.eclipse.jgit.http.server.RepositoryFilter}). + * @throws IOException if an error occurred. + */ + public Map<String, RepositoryDescription> listRepositories(Set<String> branches) + throws ServiceNotEnabledException, ServiceNotAuthorizedException, IOException; + + /** + * @return an opaque object that uniquely identifies the end-user making the + * request, and supports {@link #equals(Object)} and {@link #hashCode()}. + * Never null. + */ + public Object getUserKey(); + + /** @return the repository name associated with the request. */ + public String getRepositoryName(); + + /** + * @return the description attached to the repository of this request. + * @throws IOException an error occurred reading the description string from + * the repository. + */ + public RepositoryDescription getRepositoryDescription() throws IOException; +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java new file mode 100644 index 0000000..b2cf9ec --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesFilter.java
@@ -0,0 +1,362 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; +import static com.google.gitiles.GitilesServlet.STATIC_PREFIX; +import static com.google.gitiles.ViewFilter.getRegexGroup; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.LinkedListMultimap; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Maps; + +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.errors.RepositoryNotFoundException; +import org.eclipse.jgit.http.server.RepositoryFilter; +import org.eclipse.jgit.http.server.glue.MetaFilter; +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.storage.file.FileBasedConfig; +import org.eclipse.jgit.transport.ServiceMayNotContinueException; +import org.eclipse.jgit.transport.resolver.FileResolver; +import org.eclipse.jgit.transport.resolver.RepositoryResolver; +import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException; +import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException; +import org.eclipse.jgit.util.FS; + +import java.io.File; +import java.io.IOException; +import java.net.UnknownHostException; +import java.util.Iterator; +import java.util.Map; +import java.util.regex.Pattern; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +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.HttpServletResponse; + +/** + * MetaFilter to serve Gitiles. + * <p> + * Do not use directly; use {@link GitilesServlet}. + */ +class GitilesFilter extends MetaFilter { + private static final String CONFIG_PATH_PARAM = "configPath"; + + // The following regexes have the following capture groups: + // 1. The whole string, which causes RegexPipeline to set REGEX_GROUPS but + // not otherwise modify the original request. + // 2. The repository name part, before /<CMD>. + // 3. The command, <CMD>, with no slashes and beginning with +. Commands have + // names analogous to (but not exactly the same as) git command names, such + // as "+log" and "+show". The bare command "+" maps to one of the other + // commands based on the revision/path, and may change over time. + // 4. The revision/path part, after /<CMD> (called just "path" below). This is + // split into a revision and a path by RevisionParser. + + private static final String CMD = "\\+[a-z0-9-]*"; + + @VisibleForTesting + static final Pattern ROOT_REGEX = Pattern.compile("" + + "^( " // 1. Everything + + " /* " // Excess slashes + + " (/) " // 2. Repo name (just slash) + + " () " // 3. Command + + " () " // 4. Path + + ")$ ", + Pattern.COMMENTS); + + @VisibleForTesting + static final Pattern REPO_REGEX = Pattern.compile("" + + "^( " // 1. Everything + + " /* " // Excess slashes + + " ( " // 2. Repo name + + " / " // Leading slash + + " (?:.(?! " // Anything, as long as it's not followed by... + + " /" + CMD + "/ " // the special "/<CMD>/" separator, + + " |/" + CMD + "$ " // or "/<CMD>" at the end of the string + + " ))*? " + + " ) " + + " /* " // Trailing slashes + + " () " // 3. Command + + " () " // 4. Path + + ")$ ", + Pattern.COMMENTS); + + @VisibleForTesting + static final Pattern REPO_PATH_REGEX = Pattern.compile("" + + "^( " // 1. Everything + + " /* " // Excess slashes + + " ( " // 2. Repo name + + " / " // Leading slash + + " .*? " // Anything, non-greedy + + " ) " + + " /(" + CMD + ")" // 3. Command + + " ( " // 4. Path + + " (?:/.*)? " // Slash path, or nothing. + + " ) " + + ")$ ", + Pattern.COMMENTS); + + private static class DispatchFilter extends AbstractHttpFilter { + private final ListMultimap<GitilesView.Type, Filter> filters; + private final Map<GitilesView.Type, HttpServlet> servlets; + + private DispatchFilter(ListMultimap<GitilesView.Type, Filter> filters, + Map<GitilesView.Type, HttpServlet> servlets) { + this.filters = LinkedListMultimap.create(filters); + this.servlets = ImmutableMap.copyOf(servlets); + for (GitilesView.Type type : GitilesView.Type.values()) { + checkState(servlets.containsKey(type), "Missing handler for view %s", type); + } + } + + @Override + public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws IOException, ServletException { + GitilesView view = checkNotNull(ViewFilter.getView(req)); + final Iterator<Filter> itr = filters.get(view.getType()).iterator(); + final HttpServlet servlet = servlets.get(view.getType()); + new FilterChain() { + @Override + public void doFilter(ServletRequest req, ServletResponse res) + throws IOException, ServletException { + if (itr.hasNext()) { + itr.next().doFilter(req, res, this); + } else { + servlet.service(req, res); + } + } + }.doFilter(req, res); + } + } + + private final ListMultimap<GitilesView.Type, Filter> filters = LinkedListMultimap.create(); + private final Map<GitilesView.Type, HttpServlet> servlets = Maps.newHashMap(); + + private Renderer renderer; + private GitilesUrls urls; + private Linkifier linkifier; + private GitilesAccess.Factory accessFactory; + private RepositoryResolver<HttpServletRequest> resolver; + private VisibilityCache visibilityCache; + private boolean initialized; + + GitilesFilter() { + } + + GitilesFilter( + Renderer renderer, + GitilesUrls urls, + GitilesAccess.Factory accessFactory, + final RepositoryResolver<HttpServletRequest> resolver, + VisibilityCache visibilityCache) { + this.renderer = checkNotNull(renderer, "renderer"); + this.urls = checkNotNull(urls, "urls"); + this.accessFactory = checkNotNull(accessFactory, "accessFactory"); + this.visibilityCache = checkNotNull(visibilityCache, "visibilityCache"); + this.linkifier = new Linkifier(urls); + this.resolver = wrapResolver(resolver); + } + + @Override + public synchronized void init(FilterConfig config) throws ServletException { + super.init(config); + setDefaultFields(config); + + for (GitilesView.Type type : GitilesView.Type.values()) { + if (!servlets.containsKey(type)) { + servlets.put(type, getDefaultHandler(type)); + } + } + + Filter repositoryFilter = new RepositoryFilter(resolver); + Filter viewFilter = new ViewFilter(accessFactory, urls, visibilityCache); + Filter dispatchFilter = new DispatchFilter(filters, servlets); + String browserCssName; + String prettifyCssName; + String prettifyJsName; + + serveRegex(ROOT_REGEX) + .through(viewFilter) + .through(dispatchFilter); + + serveRegex(REPO_REGEX) + .through(repositoryFilter) + .through(viewFilter) + .through(dispatchFilter); + + serveRegex(REPO_PATH_REGEX) + .through(repositoryFilter) + .through(viewFilter) + .through(dispatchFilter); + + initialized = true; + } + + public synchronized BaseServlet getDefaultHandler(GitilesView.Type view) { + checkNotInitialized(); + switch (view) { + case HOST_INDEX: + return new HostIndexServlet(renderer, urls, accessFactory); + case REPOSITORY_INDEX: + return new RepositoryIndexServlet(renderer, accessFactory); + case REVISION: + return new RevisionServlet(renderer, linkifier); + case PATH: + return new PathServlet(renderer); + case DIFF: + return new DiffServlet(renderer, linkifier); + case LOG: + return new LogServlet(renderer, linkifier); + default: + throw new IllegalArgumentException("Invalid view type: " + view); + } + } + + synchronized void addFilter(GitilesView.Type view, Filter filter) { + checkNotInitialized(); + filters.put(checkNotNull(view, "view"), checkNotNull(filter, "filter for %s", view)); + } + + synchronized void setHandler(GitilesView.Type view, HttpServlet handler) { + checkNotInitialized(); + servlets.put(checkNotNull(view, "view"), + checkNotNull(handler, "handler for %s", view)); + } + + private synchronized void checkNotInitialized() { + checkState(!initialized, "Gitiles already initialized"); + } + + private static RepositoryResolver<HttpServletRequest> wrapResolver( + final RepositoryResolver<HttpServletRequest> resolver) { + checkNotNull(resolver, "resolver"); + return new RepositoryResolver<HttpServletRequest>() { + @Override + public Repository open(HttpServletRequest req, String name) + throws RepositoryNotFoundException, ServiceNotAuthorizedException, + ServiceNotEnabledException, ServiceMayNotContinueException { + return resolver.open(req, ViewFilter.trimLeadingSlash(getRegexGroup(req, 1))); + } + }; + } + + private void setDefaultFields(FilterConfig config) throws ServletException { + if (renderer != null && urls != null && accessFactory != null && resolver != null + && visibilityCache != null) { + return; + } + String configPath = config.getInitParameter(CONFIG_PATH_PARAM); + if (configPath == null) { + throw new ServletException("Missing required parameter " + configPath); + } + FileBasedConfig jgitConfig = new FileBasedConfig(new File(configPath), FS.DETECTED); + try { + jgitConfig.load(); + } catch (IOException e) { + throw new ServletException(e); + } catch (ConfigInvalidException e) { + throw new ServletException(e); + } + + if (renderer == null) { + String staticPrefix = config.getServletContext().getContextPath() + STATIC_PREFIX; + String customTemplates = jgitConfig.getString("gitiles", null, "customTemplates"); + // TODO(dborowitz): Automatically set to true when run with mvn jetty:run. + if (jgitConfig.getBoolean("gitiles", null, "reloadTemplates", false)) { + renderer = new DebugRenderer(staticPrefix, customTemplates, + Joiner.on(File.separatorChar).join(System.getProperty("user.dir"), + "gitiles-servlet", "src", "main", "resources", + "com", "google", "gitiles", "templates")); + } else { + renderer = new DefaultRenderer(staticPrefix, Renderer.toFileURL(customTemplates)); + } + } + if (urls == null) { + try { + urls = new DefaultUrls( + jgitConfig.getString("gitiles", null, "canonicalHostName"), + getBaseGitUrl(jgitConfig), + getGerritUrl(jgitConfig)); + } catch (UnknownHostException e) { + throw new ServletException(e); + } + } + linkifier = new Linkifier(urls); + if (accessFactory == null || resolver == null) { + String basePath = jgitConfig.getString("gitiles", null, "basePath"); + if (basePath == null) { + throw new ServletException("gitiles.basePath not set"); + } + boolean exportAll = jgitConfig.getBoolean("gitiles", null, "exportAll", false); + + FileResolver<HttpServletRequest> fileResolver; + if (resolver == null) { + fileResolver = new FileResolver<HttpServletRequest>(new File(basePath), exportAll); + resolver = wrapResolver(fileResolver); + } else if (resolver instanceof FileResolver) { + fileResolver = (FileResolver<HttpServletRequest>) resolver; + } else { + fileResolver = null; + } + if (accessFactory == null) { + checkState(fileResolver != null, "need a FileResolver when GitilesAccess.Factory not set"); + try { + accessFactory = new DefaultAccess.Factory( + new File(basePath), + getBaseGitUrl(jgitConfig), + fileResolver); + } catch (IOException e) { + throw new ServletException(e); + } + } + } + if (visibilityCache == null) { + if (jgitConfig.getSubsections("cache").contains("visibility")) { + visibilityCache = + new VisibilityCache(false, ConfigUtil.getCacheBuilder(jgitConfig, "visibility")); + } else { + visibilityCache = new VisibilityCache(false); + } + } + } + + private static String getBaseGitUrl(Config config) throws ServletException { + String baseGitUrl = config.getString("gitiles", null, "baseGitUrl"); + if (baseGitUrl == null) { + throw new ServletException("gitiles.baseGitUrl not set"); + } + return baseGitUrl; + } + + private static String getGerritUrl(Config config) throws ServletException { + String gerritUrl = config.getString("gitiles", null, "gerritUrl"); + if (gerritUrl == null) { + throw new ServletException("gitiles.gerritUrl not set"); + } + return gerritUrl; + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesServlet.java new file mode 100644 index 0000000..2cdbc02 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesServlet.java
@@ -0,0 +1,113 @@ +// Copyright 2012 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 org.eclipse.jgit.http.server.glue.MetaServlet; +import org.eclipse.jgit.transport.resolver.RepositoryResolver; + +import java.util.Enumeration; + +import javax.servlet.Filter; +import javax.servlet.FilterConfig; +import javax.servlet.ServletConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; + +/** + * Servlet to serve Gitiles. + * <p> + * This servlet can either be constructed manually with its dependencies, or + * configured to use default implementations for the Gitiles interfaces. To + * configure the defaults, you must provide a single init parameter + * "configPath", which is the path to a git config file containing additional + * configuration. + */ +public class GitilesServlet extends MetaServlet { + /** The prefix from which static resources are served. */ + public static final String STATIC_PREFIX = "/+static/"; + + public GitilesServlet(Renderer renderer, + GitilesUrls urls, + GitilesAccess.Factory accessFactory, + RepositoryResolver<HttpServletRequest> resolver, + VisibilityCache visibilityCache) { + super(new GitilesFilter(renderer, urls, accessFactory, resolver, visibilityCache)); + } + + public GitilesServlet() { + super(new GitilesFilter()); + } + + @Override + protected GitilesFilter getDelegateFilter() { + return (GitilesFilter) super.getDelegateFilter(); + } + + @Override + public void init(final ServletConfig config) throws ServletException { + getDelegateFilter().init(new FilterConfig() { + @Override + public String getFilterName() { + return getDelegateFilter().getClass().getName(); + } + + @Override + public String getInitParameter(String name) { + return config.getInitParameter(name); + } + + @SuppressWarnings("rawtypes") + @Override + public Enumeration getInitParameterNames() { + return config.getInitParameterNames(); + } + + @Override + public ServletContext getServletContext() { + return config.getServletContext(); + } + }); + } + + /** + * Add a custom filter for a view. + * <p> + * Must be called before initializing the servlet. + * + * @param view view type. + * @param filter filter. + */ + public void addFilter(GitilesView.Type view, Filter filter) { + getDelegateFilter().addFilter(view, filter); + } + + /** + * Set a custom handler for a view. + * <p> + * Must be called before initializing the servlet. + * + * @param view view type. + * @param handler handler. + */ + public void setHandler(GitilesView.Type view, HttpServlet handler) { + getDelegateFilter().setHandler(view, handler); + } + + public BaseServlet getDefaultHandler(GitilesView.Type view) { + return getDelegateFilter().getDefaultHandler(view); + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesUrls.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesUrls.java new file mode 100644 index 0000000..fb7ff3b --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesUrls.java
@@ -0,0 +1,79 @@ +// Copyright 2012 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 com.google.common.base.Charsets; +import com.google.common.base.Function; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; + +import javax.servlet.http.HttpServletRequest; + +/** Interface for URLs displayed on source browsing pages. */ +public interface GitilesUrls { + /** + * Escapes repository or path names to be safely embedded into a URL. + * <p> + * This escape implementation escapes a repository or path name such as + * "foo/bar</child" to appear as "foo/bar%3C/child". Spaces are escaped as + * "%20". Its purpose is to escape a repository name to be safe for inclusion + * in the path component of the URL, where "/" is a valid character that + * should not be encoded, while almost any other non-alpha, non-numeric + * character will be encoded using URL style encoding. + */ + public static final Function<String, String> NAME_ESCAPER = new Function<String, String>() { + @Override + public String apply(String s) { + try { + return URLEncoder.encode(s, Charsets.UTF_8.name()) + .replace("%2F", "/") + .replace("%2f", "/") + .replace("+", "%20") + .replace("%2B", "+") + .replace("%2b", "+"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + }; + + /** + * Return the name of the host from the request. + * + * Used in various user-visible text, like "MyHost Git Repositories". + * + * @param req request. + * @return host name; may be null. + */ + public String getHostName(HttpServletRequest req); + + /** + * Return the base URL for git repositories on this host. + * + * @param req request. + * @return base URL for git repositories. + */ + public String getBaseGitUrl(HttpServletRequest req); + + /** + * Return the base URL for Gerrit projects on this host. + * + * @param req request. + * @return base URL for Gerrit Code Review, or null if Gerrit is not + * configured. + */ + public String getBaseGerritUrl(HttpServletRequest req); +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java new file mode 100644 index 0000000..ad685c4 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/GitilesView.java
@@ -0,0 +1,563 @@ +// Copyright 2012 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 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.base.Charsets; +import com.google.common.base.Objects; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.LinkedListMultimap; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Multimaps; + +import org.eclipse.jgit.revwalk.RevObject; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; + +/** + * Information about a view in Gitiles. + * <p> + * Views are uniquely identified by a type, and dispatched to servlet types by + * {@link GitilesServlet}. This class contains the list of all types, as + * well as some methods containing basic information parsed from the URL. + * Construction happens in {@link ViewFilter}. + */ +public class GitilesView { + /** All the possible view types supported in the application. */ + public static enum Type { + HOST_INDEX, + REPOSITORY_INDEX, + REVISION, + PATH, + DIFF, + LOG; + } + + /** Builder for views. */ + public static class Builder { + private final Type type; + private final ListMultimap<String, String> params = LinkedListMultimap.create(); + + private String hostName; + private String servletPath; + private String repositoryName; + private Revision revision = Revision.NULL; + private Revision oldRevision = Revision.NULL; + private String path; + private String anchor; + + private Builder(Type type) { + this.type = type; + } + + public Builder copyFrom(GitilesView other) { + hostName = other.hostName; + servletPath = other.servletPath; + switch (type) { + case LOG: + case DIFF: + oldRevision = other.oldRevision; + // Fallthrough. + case PATH: + path = other.path; + // Fallthrough. + case REVISION: + revision = other.revision; + // Fallthrough. + case REPOSITORY_INDEX: + repositoryName = other.repositoryName; + } + // Don't copy params. + return this; + } + + public Builder copyFrom(HttpServletRequest req) { + return copyFrom(ViewFilter.getView(req)); + } + + public Builder setHostName(String hostName) { + this.hostName = checkNotNull(hostName); + return this; + } + + public String getHostName() { + return hostName; + } + + public Builder setServletPath(String servletPath) { + this.servletPath = checkNotNull(servletPath); + return this; + } + + public String getServletPath() { + return servletPath; + } + + public Builder setRepositoryName(String repositoryName) { + switch (type) { + case HOST_INDEX: + throw new IllegalStateException(String.format( + "cannot set repository name on %s view", type)); + default: + this.repositoryName = checkNotNull(repositoryName); + return this; + } + } + + public String getRepositoryName() { + return repositoryName; + } + + public Builder setRevision(Revision revision) { + switch (type) { + case HOST_INDEX: + case REPOSITORY_INDEX: + throw new IllegalStateException(String.format("cannot set revision on %s view", type)); + default: + this.revision = checkNotNull(revision); + return this; + } + } + + public Builder setRevision(String name) { + return setRevision(Revision.named(name)); + } + + public Builder setRevision(RevObject obj) { + return setRevision(Revision.peeled(obj.name(), obj)); + } + + public Builder setRevision(String name, RevObject obj) { + return setRevision(Revision.peeled(name, obj)); + } + + public Revision getRevision() { + return revision; + } + + public Builder setOldRevision(Revision revision) { + switch (type) { + case DIFF: + case LOG: + this.oldRevision = checkNotNull(revision); + return this; + default: + throw new IllegalStateException( + String.format("cannot set old revision on %s view", type)); + } + } + + public Builder setOldRevision(RevObject obj) { + return setOldRevision(Revision.peeled(obj.name(), obj)); + } + + public Builder setOldRevision(String name, RevObject obj) { + return setOldRevision(Revision.peeled(name, obj)); + } + + public Revision getOldRevision() { + return revision; + } + + public Builder setTreePath(String path) { + switch (type) { + case PATH: + case DIFF: + this.path = maybeTrimLeadingAndTrailingSlash(checkNotNull(path)); + return this; + case LOG: + this.path = path != null ? maybeTrimLeadingAndTrailingSlash(path) : null; + return this; + default: + throw new IllegalStateException(String.format("cannot set path on %s view", type)); + } + } + + public String getTreePath() { + return path; + } + + public Builder putParam(String key, String value) { + params.put(key, value); + return this; + } + + public Builder replaceParam(String key, String value) { + params.replaceValues(key, ImmutableList.of(value)); + return this; + } + + public Builder putAllParams(Map<String, String[]> params) { + for (Map.Entry<String, String[]> e : params.entrySet()) { + for (String v : e.getValue()) { + this.params.put(e.getKey(), v); + } + } + return this; + } + + public ListMultimap<String, String> getParams() { + return params; + } + + public Builder setAnchor(String anchor) { + this.anchor = anchor; + return this; + } + + public String getAnchor() { + return anchor; + } + + public GitilesView build() { + switch (type) { + case HOST_INDEX: + checkHostIndex(); + break; + case REPOSITORY_INDEX: + checkRepositoryIndex(); + break; + case REVISION: + checkRevision(); + break; + case PATH: + checkPath(); + break; + case DIFF: + checkDiff(); + break; + case LOG: + checkLog(); + break; + } + return new GitilesView(type, hostName, servletPath, repositoryName, revision, + oldRevision, path, params, anchor); + } + + public String toUrl() { + return build().toUrl(); + } + + private void checkHostIndex() { + checkState(hostName != null, "missing hostName on %s view", type); + checkState(servletPath != null, "missing hostName on %s view", type); + } + + private void checkRepositoryIndex() { + checkState(repositoryName != null, "missing repository name on %s view", type); + checkHostIndex(); + } + + private void checkRevision() { + checkState(revision != Revision.NULL, "missing revision on %s view", type); + checkRepositoryIndex(); + } + + private void checkDiff() { + checkPath(); + } + + private void checkLog() { + checkRevision(); + } + + private void checkPath() { + checkState(path != null, "missing path on %s view", type); + checkRevision(); + } + } + + public static Builder hostIndex() { + return new Builder(Type.HOST_INDEX); + } + + public static Builder repositoryIndex() { + return new Builder(Type.REPOSITORY_INDEX); + } + + public static Builder revision() { + return new Builder(Type.REVISION); + } + + public static Builder path() { + return new Builder(Type.PATH); + } + + public static Builder diff() { + return new Builder(Type.DIFF); + } + + public static Builder log() { + return new Builder(Type.LOG); + } + + private static String maybeTrimLeadingAndTrailingSlash(String str) { + if (str.startsWith("/")) { + str = str.substring(1); + } + return !str.isEmpty() && str.endsWith("/") ? str.substring(0, str.length() - 1) : str; + } + + private final Type type; + private final String hostName; + private final String servletPath; + private final String repositoryName; + private final Revision revision; + private final Revision oldRevision; + private final String path; + private final ListMultimap<String, String> params; + private final String anchor; + + private GitilesView(Type type, + String hostName, + String servletPath, + String repositoryName, + Revision revision, + Revision oldRevision, + String path, + ListMultimap<String, String> params, + String anchor) { + this.type = type; + this.hostName = hostName; + this.servletPath = servletPath; + this.repositoryName = repositoryName; + this.revision = Objects.firstNonNull(revision, Revision.NULL); + this.oldRevision = Objects.firstNonNull(oldRevision, Revision.NULL); + this.path = path; + this.params = Multimaps.unmodifiableListMultimap(params); + this.anchor = anchor; + } + + public String getHostName() { + return hostName; + } + + public String getServletPath() { + return servletPath; + } + + public String getRepositoryName() { + return repositoryName; + } + + public Revision getRevision() { + return revision; + } + + public Revision getOldRevision() { + return oldRevision; + } + + public String getRevisionRange() { + if (oldRevision == Revision.NULL) { + switch (type) { + case LOG: + case DIFF: + // For types that require two revisions, NULL indicates the empty + // tree/commit. + return revision.getName() + "^!"; + default: + // For everything else NULL indicates it is not a range, just a single + // revision. + return null; + } + } else if (type == Type.DIFF && isFirstParent(revision, oldRevision)) { + return revision.getName() + "^!"; + } else { + return oldRevision.getName() + ".." + revision.getName(); + } + } + + public String getTreePath() { + return path; + } + + public ListMultimap<String, String> getParameters() { + return params; + } + + public String getAnchor() { + return anchor; + } + + public Type getType() { + return type; + } + + /** @return an escaped, relative URL representing this view. */ + public String toUrl() { + StringBuilder url = new StringBuilder(servletPath).append('/'); + ListMultimap<String, String> params = this.params; + switch (type) { + case HOST_INDEX: + params = LinkedListMultimap.create(); + if (!this.params.containsKey("format")) { + params.put("format", FormatType.HTML.toString()); + } + params.putAll(this.params); + break; + case REPOSITORY_INDEX: + url.append(repositoryName).append('/'); + break; + case REVISION: + url.append(repositoryName).append("/+"); + if (!getRevision().nameIsId()) { + url.append("show"); // Default for /+/master is +log. + } + url.append('/').append(revision.getName()); + break; + case PATH: + url.append(repositoryName).append("/+/").append(revision.getName()).append('/') + .append(path); + break; + case DIFF: + url.append(repositoryName).append("/+/"); + if (isFirstParent(revision, oldRevision)) { + url.append(revision.getName()).append("^!"); + } else { + url.append(oldRevision.getName()).append("..").append(revision.getName()); + } + url.append('/').append(path); + break; + case LOG: + url.append(repositoryName).append("/+"); + if (getRevision().nameIsId() || oldRevision != Revision.NULL || path != null) { + // Default for /+/c0ffee/(...) is +show. + // Default for /+/c0ffee..deadbeef(/...) is +diff. + url.append("log"); + } + url.append('/'); + if (oldRevision != Revision.NULL) { + url.append(oldRevision.getName()).append(".."); + } + url.append(revision.getName()); + if (path != null) { + url.append('/').append(path); + } + break; + default: + throw new IllegalStateException("Unknown view type: " + type); + } + String baseUrl = NAME_ESCAPER.apply(url.toString()); + url = new StringBuilder(); + if (!params.isEmpty()) { + url.append('?').append(paramsToString(params)); + } + if (!Strings.isNullOrEmpty(anchor)) { + url.append('#').append(NAME_ESCAPER.apply(anchor)); + } + return baseUrl + url.toString(); + } + + public List<Map<String, String>> getBreadcrumbs() { + String path = this.path; + ImmutableList.Builder<Map<String, String>> breadcrumbs = ImmutableList.builder(); + breadcrumbs.add(breadcrumb(hostName, hostIndex().copyFrom(this))); + if (repositoryName != null) { + breadcrumbs.add(breadcrumb(repositoryName, repositoryIndex().copyFrom(this))); + } + if (type == Type.DIFF) { + // TODO(dborowitz): Tweak the breadcrumbs template to allow us to render + // separate links in "old..new". + breadcrumbs.add(breadcrumb(getRevisionRange(), diff().copyFrom(this).setTreePath(""))); + } else if (type == Type.LOG) { + // TODO(dborowitz): Add something in the navigation area (probably not + // a breadcrumb) to allow switching between /+log/ and /+/. + if (oldRevision == Revision.NULL) { + breadcrumbs.add(breadcrumb(revision.getName(), log().copyFrom(this).setTreePath(null))); + } else { + breadcrumbs.add(breadcrumb(getRevisionRange(), log().copyFrom(this).setTreePath(null))); + } + path = Strings.emptyToNull(path); + } else if (revision != Revision.NULL) { + breadcrumbs.add(breadcrumb(revision.getName(), revision().copyFrom(this))); + } + if (path != null) { + if (type != Type.LOG) { // The "." breadcrumb would be no different for LOG. + breadcrumbs.add(breadcrumb(".", copyWithPath().setTreePath(""))); + } + StringBuilder cur = new StringBuilder(); + boolean first = true; + for (String part : RevisionParser.PATH_SPLITTER.omitEmptyStrings().split(path)) { + if (!first) { + cur.append('/'); + } else { + first = false; + } + cur.append(part); + breadcrumbs.add(breadcrumb(part, copyWithPath().setTreePath(cur.toString()))); + } + } + return breadcrumbs.build(); + } + + private static Map<String, String> breadcrumb(String text, Builder url) { + return ImmutableMap.of("text", text, "url", url.toUrl()); + } + + private Builder copyWithPath() { + Builder copy; + switch (type) { + case DIFF: + copy = diff(); + break; + case LOG: + copy = log(); + break; + default: + copy = path(); + break; + } + return copy.copyFrom(this); + } + + private static boolean isFirstParent(Revision rev1, Revision rev2) { + return rev2 == Revision.NULL + || rev2.getName().equals(rev1.getName() + "^") + || rev2.getName().equals(rev1.getName() + "~1"); + } + + private static String paramsToString(ListMultimap<String, String> params) { + try { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (Map.Entry<String, String> e : params.entries()) { + if (!first) { + sb.append('&'); + } else { + first = false; + } + sb.append(URLEncoder.encode(e.getKey(), Charsets.UTF_8.name())); + if (!"".equals(e.getValue())) { + sb.append('=') + .append(URLEncoder.encode(e.getValue(), Charsets.UTF_8.name())); + } + } + return sb.toString(); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java new file mode 100644 index 0000000..bf9ffd1 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/HostIndexServlet.java
@@ -0,0 +1,199 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkNotNull; +import static com.google.gitiles.FormatType.JSON; +import static com.google.gitiles.FormatType.TEXT; +import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST; +import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN; +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; +import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE; +import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Sets; +import com.google.common.net.HttpHeaders; +import com.google.gson.FieldNamingPolicy; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import com.google.template.soy.data.SoyListData; +import com.google.template.soy.data.SoyMapData; + +import org.eclipse.jgit.errors.RepositoryNotFoundException; +import org.eclipse.jgit.transport.ServiceMayNotContinueException; +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 java.io.PrintWriter; +import java.util.Arrays; +import java.util.Map; +import java.util.Set; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** Serves the top level index page for a Gitiles host. */ +public class HostIndexServlet extends BaseServlet { + private static final Logger log = LoggerFactory.getLogger(HostIndexServlet.class); + + protected final GitilesUrls urls; + private final GitilesAccess.Factory accessFactory; + + public HostIndexServlet(Renderer renderer, GitilesUrls urls, + GitilesAccess.Factory accessFactory) { + super(renderer); + this.urls = checkNotNull(urls, "urls"); + this.accessFactory = checkNotNull(accessFactory, "accessFactory"); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { + FormatType format; + try { + format = FormatType.getFormatType(req); + } catch (IllegalArgumentException err) { + res.sendError(SC_BAD_REQUEST); + return; + } + + Set<String> branches = parseShowBranch(req); + Map<String, RepositoryDescription> descs; + try { + descs = accessFactory.forRequest(req).listRepositories(branches); + } catch (RepositoryNotFoundException e) { + res.sendError(SC_NOT_FOUND); + return; + } catch (ServiceNotEnabledException e) { + res.sendError(SC_FORBIDDEN); + return; + } catch (ServiceNotAuthorizedException e) { + res.sendError(SC_UNAUTHORIZED); + return; + } catch (ServiceMayNotContinueException e) { + // TODO(dborowitz): Show the error message to the user. + res.sendError(SC_FORBIDDEN); + return; + } catch (IOException err) { + String name = urls.getHostName(req); + log.warn("Cannot scan repositories" + (name != null ? "for " + name : ""), err); + res.sendError(SC_SERVICE_UNAVAILABLE); + return; + } + + switch (format) { + case HTML: + case DEFAULT: + default: + displayHtml(req, res, descs); + break; + + case TEXT: + displayText(req, res, branches, descs); + break; + + case JSON: + displayJson(req, res, descs); + break; + } + } + + private SoyMapData toSoyMapData(RepositoryDescription desc, GitilesView view) { + return new SoyMapData( + "name", desc.name, + "description", Strings.nullToEmpty(desc.description), + "url", GitilesView.repositoryIndex() + .copyFrom(view) + .setRepositoryName(desc.name) + .toUrl()); + } + + private void displayHtml(HttpServletRequest req, HttpServletResponse res, + Map<String, RepositoryDescription> descs) throws IOException { + SoyListData repos = new SoyListData(); + for (RepositoryDescription desc : descs.values()) { + repos.add(toSoyMapData(desc, ViewFilter.getView(req))); + } + + render(req, res, "gitiles.hostIndex", ImmutableMap.of( + "hostName", urls.getHostName(req), + "baseUrl", urls.getBaseGitUrl(req), + "repositories", repos)); + } + + private void displayText(HttpServletRequest req, HttpServletResponse res, + Set<String> branches, Map<String, RepositoryDescription> descs) throws IOException { + res.setContentType(TEXT.getMimeType()); + res.setCharacterEncoding("UTF-8"); + res.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment"); + setNotCacheable(res); + + PrintWriter writer = res.getWriter(); + for (RepositoryDescription repo : descs.values()) { + for (String name : branches) { + String ref = repo.branches.get(name); + if (ref == null) { + // Print stub (forty '-' symbols) + ref = "----------------------------------------"; + } + writer.print(ref); + writer.print(' '); + } + writer.print(GitilesUrls.NAME_ESCAPER.apply(repo.name)); + writer.print('\n'); + } + writer.flush(); + writer.close(); + } + + private void displayJson(HttpServletRequest req, HttpServletResponse res, + Map<String, RepositoryDescription> descs) throws IOException { + res.setContentType(JSON.getMimeType()); + res.setCharacterEncoding("UTF-8"); + res.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment"); + setNotCacheable(res); + + PrintWriter writer = res.getWriter(); + new GsonBuilder() + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .setPrettyPrinting() + .generateNonExecutableJson() + .create() + .toJson(descs, + new TypeToken<Map<String, RepositoryDescription>>() {}.getType(), + writer); + writer.print('\n'); + writer.close(); + } + + private static Set<String> parseShowBranch(HttpServletRequest req) { + // Roughly match Gerrit Code Review's /projects/ API by supporting + // both show-branch and b as query parameters. + Set<String> branches = Sets.newLinkedHashSet(); + String[] values = req.getParameterValues("show-branch"); + if (values != null) { + branches.addAll(Arrays.asList(values)); + } + values = req.getParameterValues("b"); + if (values != null) { + branches.addAll(Arrays.asList(values)); + } + return branches; + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/HtmlDiffFormatter.java b/gitiles-servlet/src/main/java/com/google/gitiles/HtmlDiffFormatter.java new file mode 100644 index 0000000..63b1eb8 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/HtmlDiffFormatter.java
@@ -0,0 +1,128 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableMap; + +import org.apache.commons.lang3.StringEscapeUtils; +import org.eclipse.jgit.diff.DiffEntry; +import org.eclipse.jgit.diff.DiffFormatter; +import org.eclipse.jgit.diff.RawText; +import org.eclipse.jgit.patch.FileHeader; +import org.eclipse.jgit.patch.FileHeader.PatchType; +import org.eclipse.jgit.util.RawParseUtils; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; + +/** Formats a unified format patch as UTF-8 encoded HTML. */ +final class HtmlDiffFormatter extends DiffFormatter { + private static final byte[] DIFF_BEGIN = "<pre class=\"diff-unified\">".getBytes(Charsets.UTF_8); + private static final byte[] DIFF_END = "</pre>".getBytes(Charsets.UTF_8); + + private static final byte[] HUNK_BEGIN = "<span class=\"h\">".getBytes(Charsets.UTF_8); + private static final byte[] HUNK_END = "</span>".getBytes(Charsets.UTF_8); + + private static final byte[] LINE_INSERT_BEGIN = "<span class=\"i\">".getBytes(Charsets.UTF_8); + private static final byte[] LINE_DELETE_BEGIN = "<span class=\"d\">".getBytes(Charsets.UTF_8); + private static final byte[] LINE_CHANGE_BEGIN = "<span class=\"c\">".getBytes(Charsets.UTF_8); + private static final byte[] LINE_END = "</span>\n".getBytes(Charsets.UTF_8); + + private final Renderer renderer; + private int fileIndex; + + HtmlDiffFormatter(Renderer renderer, OutputStream out) { + super(out); + this.renderer = checkNotNull(renderer, "renderer"); + } + + @Override + public void format(List<? extends DiffEntry> entries) throws IOException { + for (fileIndex = 0; fileIndex < entries.size(); fileIndex++) { + format(entries.get(fileIndex)); + } + } + + @Override + public void format(FileHeader hdr, RawText a, RawText b) + throws IOException { + int start = hdr.getStartOffset(); + int end = hdr.getEndOffset(); + if (!hdr.getHunks().isEmpty()) { + end = hdr.getHunks().get(0).getStartOffset(); + } + renderHeader(RawParseUtils.decode(hdr.getBuffer(), start, end)); + + if (hdr.getPatchType() == PatchType.UNIFIED) { + getOutputStream().write(DIFF_BEGIN); + format(hdr.toEditList(), a, b); + getOutputStream().write(DIFF_END); + } + } + + private void renderHeader(String header) + throws IOException { + int lf = header.indexOf('\n'); + String first; + String rest; + if (0 <= lf) { + first = header.substring(0, lf); + rest = header.substring(lf + 1); + } else { + first = header; + rest = ""; + } + getOutputStream().write(renderer.newRenderer("gitiles.diffHeader") + .setData(ImmutableMap.of("first", first, "rest", rest, "fileIndex", fileIndex)) + .render() + .getBytes(Charsets.UTF_8)); + } + + @Override + protected void writeHunkHeader(int aStartLine, int aEndLine, + int bStartLine, int bEndLine) throws IOException { + getOutputStream().write(HUNK_BEGIN); + // TODO(sop): If hunk header starts including method names, escape it. + super.writeHunkHeader(aStartLine, aEndLine, bStartLine, bEndLine); + getOutputStream().write(HUNK_END); + } + + @Override + protected void writeLine(char prefix, RawText text, int cur) + throws IOException { + // Manually render each line, rather than invoke a Soy template. This method + // can be called thousands of times in a single request. Avoid unnecessary + // overheads by formatting as-is. + OutputStream out = getOutputStream(); + switch (prefix) { + case '+': + out.write(LINE_INSERT_BEGIN); + break; + case '-': + out.write(LINE_DELETE_BEGIN); + break; + case ' ': + default: + out.write(LINE_CHANGE_BEGIN); + break; + } + out.write(StringEscapeUtils.escapeHtml4(text.getString(cur)).getBytes(Charsets.UTF_8)); + out.write(LINE_END); + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Linkifier.java b/gitiles-servlet/src/main/java/com/google/gitiles/Linkifier.java new file mode 100644 index 0000000..735e6f5 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/Linkifier.java
@@ -0,0 +1,96 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; + +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.servlet.http.HttpServletRequest; + +/** Linkifier for blocks of text such as commit message descriptions. */ +public class Linkifier { + private static final Pattern LINK_PATTERN; + + static { + // HTTP URL regex adapted from com.google.gwtexpui.safehtml.client.SafeHtml. + String part = "[a-zA-Z0-9$_.+!*',%;:@=?#/~<>-]"; + String httpUrl = "https?://" + + part + "{2,}" + + "(?:[(]" + part + "*" + "[)])*" + + part + "*"; + String changeId = "\\bI[0-9a-f]{8,40}\\b"; + LINK_PATTERN = Pattern.compile(Joiner.on("|").join( + "(" + httpUrl + ")", + "(" + changeId + ")")); + } + + private final GitilesUrls urls; + + public Linkifier(GitilesUrls urls) { + this.urls = checkNotNull(urls, "urls"); + } + + public List<Map<String, String>> linkify(HttpServletRequest req, String message) { + String baseGerritUrl = urls.getBaseGerritUrl(req); + List<Map<String, String>> parsed = Lists.newArrayList(); + Matcher m = LINK_PATTERN.matcher(message); + int last = 0; + while (m.find()) { + addText(parsed, message.substring(last, m.start())); + if (m.group(1) != null) { + // Bare URL. + parsed.add(link(m.group(1), m.group(1))); + } else if (m.group(2) != null) { + if (baseGerritUrl != null) { + // Gerrit Change-Id. + parsed.add(link(m.group(2), baseGerritUrl + "#/q/" + m.group(2) + ",n,z")); + } else { + addText(parsed, m.group(2)); + } + } + last = m.end(); + } + addText(parsed, message.substring(last)); + return parsed; + } + + private static Map<String, String> link(String text, String url) { + return ImmutableMap.of("text", text, "url", url); + } + + private static void addText(List<Map<String, String>> parts, String text) { + if (text.isEmpty()) { + return; + } + if (parts.isEmpty()) { + parts.add(ImmutableMap.of("text", text)); + } else { + Map<String, String> old = parts.get(parts.size() - 1); + if (!old.containsKey("url")) { + parts.set(parts.size() - 1, ImmutableMap.of("text", old.get("text") + text)); + } else { + parts.add(ImmutableMap.of("text", text)); + } + } + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java new file mode 100644 index 0000000..07977f7 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/LogServlet.java
@@ -0,0 +1,193 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; + +import com.google.common.base.Optional; +import com.google.common.base.Strings; +import com.google.common.collect.Iterables; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.gitiles.CommitSoyData.KeySet; + +import org.eclipse.jgit.errors.IncorrectObjectTypeException; +import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.errors.RevWalkException; +import org.eclipse.jgit.http.server.ServletUtils; +import org.eclipse.jgit.lib.AbbreviatedObjectId; +import org.eclipse.jgit.lib.AnyObjectId; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.FollowFilter; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevObject; +import org.eclipse.jgit.revwalk.RevTag; +import org.eclipse.jgit.revwalk.RevWalk; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** Serves an HTML page with a shortlog for commits and paths. */ +public class LogServlet extends BaseServlet { + private static final Logger log = LoggerFactory.getLogger(LogServlet.class); + + private static final String START_PARAM = "s"; + + private final Linkifier linkifier; + private final int limit; + + public LogServlet(Renderer renderer, Linkifier linkifier) { + this(renderer, linkifier, 100); + } + + private LogServlet(Renderer renderer, Linkifier linkifier, int limit) { + super(renderer); + this.linkifier = checkNotNull(linkifier, "linkifier"); + checkArgument(limit >= 0, "limit must be positive: %s", limit); + this.limit = limit; + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { + GitilesView view = ViewFilter.getView(req); + Repository repo = ServletUtils.getRepository(req); + RevWalk walk = null; + try { + try { + walk = newWalk(repo, view); + } catch (IncorrectObjectTypeException e) { + res.setStatus(SC_NOT_FOUND); + return; + } + + Optional<ObjectId> start = getStart(view.getParameters(), walk.getObjectReader()); + if (start == null) { + res.setStatus(SC_NOT_FOUND); + return; + } + + Map<String, Object> data = Maps.newHashMapWithExpectedSize(5); + + if (!view.getRevision().nameIsId()) { + List<Map<String, Object>> tags = Lists.newArrayListWithExpectedSize(1); + for (RevObject o : RevisionServlet.listObjects(walk, view.getRevision().getId())) { + if (o instanceof RevTag) { + tags.add(new TagSoyData(linkifier, req).toSoyData((RevTag) o)); + } + } + if (!tags.isEmpty()) { + data.put("tags", tags); + } + } + + Paginator paginator = new Paginator(walk, limit, start.orNull()); + Map<AnyObjectId, Set<Ref>> refsById = repo.getAllRefsByPeeledObjectId(); + List<Map<String, Object>> entries = Lists.newArrayListWithCapacity(limit); + for (RevCommit c : paginator) { + entries.add(new CommitSoyData(null, req, repo, walk, view, refsById) + .toSoyData(c, KeySet.SHORTLOG)); + } + + String title = "Log - "; + if (view.getOldRevision() != Revision.NULL) { + title += view.getRevisionRange(); + } else { + title += view.getRevision().getName(); + } + + data.put("title", title); + data.put("entries", entries); + ObjectId next = paginator.getNextStart(); + if (next != null) { + data.put("nextUrl", copyAndCanonicalize(view) + .replaceParam(START_PARAM, next.name()) + .toUrl()); + } + ObjectId prev = paginator.getPreviousStart(); + if (prev != null) { + GitilesView.Builder prevView = copyAndCanonicalize(view); + if (!prevView.getRevision().getId().equals(prev)) { + prevView.replaceParam(START_PARAM, prev.name()); + } + data.put("previousUrl", prevView.toUrl()); + } + + render(req, res, "gitiles.logDetail", data); + } catch (RevWalkException e) { + log.warn("Error in rev walk", e); + res.setStatus(SC_INTERNAL_SERVER_ERROR); + return; + } finally { + if (walk != null) { + walk.release(); + } + } + } + + private static GitilesView.Builder copyAndCanonicalize(GitilesView view) { + // Canonicalize the view by using full SHAs. + GitilesView.Builder copy = GitilesView.log().copyFrom(view) + .setRevision(view.getRevision()); + if (view.getOldRevision() != Revision.NULL) { + copy.setOldRevision(view.getOldRevision()); + } + return copy; + } + + private static Optional<ObjectId> getStart(ListMultimap<String, String> params, + ObjectReader reader) throws IOException { + List<String> values = params.get(START_PARAM); + switch (values.size()) { + case 0: + return Optional.absent(); + case 1: + Collection<ObjectId> ids = reader.resolve(AbbreviatedObjectId.fromString(values.get(0))); + if (ids.size() != 1) { + return null; + } + return Optional.of(Iterables.getOnlyElement(ids)); + default: + return null; + } + } + + private static RevWalk newWalk(Repository repo, GitilesView view) + throws MissingObjectException, IncorrectObjectTypeException, IOException { + RevWalk walk = new RevWalk(repo); + walk.markStart(walk.parseCommit(view.getRevision().getId())); + if (view.getOldRevision() != Revision.NULL) { + walk.markUninteresting(walk.parseCommit(view.getOldRevision().getId())); + } + if (!Strings.isNullOrEmpty(view.getTreePath())) { + walk.setTreeFilter(FollowFilter.create(view.getTreePath())); + } + return walk; + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Paginator.java b/gitiles-servlet/src/main/java/com/google/gitiles/Paginator.java new file mode 100644 index 0000000..0454556 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/Paginator.java
@@ -0,0 +1,176 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import org.eclipse.jgit.errors.IncorrectObjectTypeException; +import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.errors.RevWalkException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Iterator; + +import javax.annotation.Nullable; + +/** + * Wrapper around {@link RevWalk} that paginates for Gitiles. + * + * A single page of a shortlog is defined by a revision range, such as "master" + * or "master..next", a page size, and a start commit, such as "c0ffee". The + * distance between the first commit in the walk ("next") and the first commit + * in the page may be arbitrarily long, but in order to present the commit list + * in a stable way, we must always start from the first commit in the walk. This + * is because there may be arbitrary merge commits between "c0ffee" and "next" + * that effectively insert arbitrary commits into the history starting from + * "c0ffee". + */ +class Paginator implements Iterable<RevCommit> { + private final RevWalk walk; + private final ObjectId start; + private final int limit; + private final Deque<ObjectId> prevBuffer; + + private boolean done; + private int i; + private int n; + private int foundIndex; + private ObjectId nextStart; + + /** + * @param walk revision walk. + * @param limit page size. + * @param start commit at which to start the walk, or null to start at the + * beginning. + */ + Paginator(RevWalk walk, int limit, @Nullable ObjectId start) { + this.walk = checkNotNull(walk, "walk"); + this.start = start; + checkArgument(limit > 0, "limit must be positive: %s", limit); + this.limit = limit; + prevBuffer = new ArrayDeque<ObjectId>(start != null ? limit : 0); + i = -1; + foundIndex = -1; + } + + /** + * Get the next element in this page of the walk. + * + * @return the next element, or null if the walk is finished. + * + * @throws MissingObjectException See {@link RevWalk#next()}. + * @throws IncorrectObjectTypeException See {@link RevWalk#next()}. + * @throws IOException See {@link RevWalk#next()}. + */ + public RevCommit next() throws MissingObjectException, IncorrectObjectTypeException, + IOException { + RevCommit commit; + if (foundIndex < 0) { + while (true) { + commit = walk.next(); + if (commit == null) { + done = true; + return null; + } + i++; + if (start == null || start.equals(commit)) { + foundIndex = i; + break; + } + if (prevBuffer.size() == limit) { + prevBuffer.remove(); + } + prevBuffer.add(commit); + } + } else { + commit = walk.next(); + } + + if (++n == limit) { + done = true; + } else if (n == limit + 1 || commit == null) { + nextStart = commit; + done = true; + return null; + } + return commit; + } + + /** + * @return the ID at the start of the page of results preceding this one, or + * null if this is the first page. + */ + public ObjectId getPreviousStart() { + checkState(done, "getPreviousStart() invalid before walk done"); + return prevBuffer.pollFirst(); + } + + /** + * @return the ID at the start of the page of results after this one, or null + * if this is the last page. + */ + public ObjectId getNextStart() { + checkState(done, "getNextStart() invalid before walk done"); + return nextStart; + } + + /** + * @return an iterator over the commits in this walk. + * @throws RevWalkException if an error occurred, wrapping the checked + * exception from {@link #next()}. + */ + @Override + public Iterator<RevCommit> iterator() { + return new Iterator<RevCommit>() { + RevCommit next = nextUnchecked(); + + @Override + public boolean hasNext() { + return next != null; + } + + @Override + public RevCommit next() { + RevCommit r = next; + next = nextUnchecked(); + return r; + } + + @Override + public void remove() { + throw new UnsupportedOperationException(); + } + }; + } + + private RevCommit nextUnchecked() { + try { + return next(); + } catch (MissingObjectException e) { + throw new RevWalkException(e); + } catch (IncorrectObjectTypeException e) { + throw new RevWalkException(e); + } catch (IOException e) { + throw new RevWalkException(e); + } + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java new file mode 100644 index 0000000..d228202 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/PathServlet.java
@@ -0,0 +1,276 @@ +// Copyright 2012 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 com.google.gitiles.TreeSoyData.resolveTargetUrl; +import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; +import static org.eclipse.jgit.lib.Constants.OBJ_BLOB; +import static org.eclipse.jgit.lib.Constants.OBJ_COMMIT; +import static org.eclipse.jgit.lib.Constants.OBJ_TREE; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; + +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.errors.LargeObjectException; +import org.eclipse.jgit.http.server.ServletUtils; +import org.eclipse.jgit.lib.FileMode; +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.RevObject; +import org.eclipse.jgit.revwalk.RevTree; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.submodule.SubmoduleWalk; +import org.eclipse.jgit.treewalk.TreeWalk; +import org.eclipse.jgit.util.RawParseUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Map; +import java.util.regex.Pattern; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** Serves an HTML page with detailed information about a path within a tree. */ +// TODO(dborowitz): Handle non-UTF-8 names. +public class PathServlet extends BaseServlet { + private static final Logger log = LoggerFactory.getLogger(PathServlet.class); + + /** + * Submodule URLs where we know there is a web page if the user visits the + * repository URL verbatim in a web browser. + */ + private static final Pattern VERBATIM_SUBMODULE_URL_PATTERN = + Pattern.compile("^(" + Joiner.on('|').join( + "https?://[^.]+.googlesource.com/.*", + "https?://[^.]+.googlecode.com/.*", + "https?://code.google.com/p/.*", + "https?://github.com/.*") + ")$", Pattern.CASE_INSENSITIVE); + + static enum FileType { + TREE(FileMode.TREE), + SYMLINK(FileMode.SYMLINK), + REGULAR_FILE(FileMode.REGULAR_FILE), + EXECUTABLE_FILE(FileMode.EXECUTABLE_FILE), + GITLINK(FileMode.GITLINK); + + private final FileMode mode; + + private FileType(FileMode mode) { + this.mode = mode; + } + + static FileType forEntry(TreeWalk tw) { + int mode = tw.getRawMode(0); + for (FileType type : values()) { + if (type.mode.equals(mode)) { + return type; + } + } + return null; + } + } + + public PathServlet(Renderer renderer) { + super(renderer); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { + GitilesView view = ViewFilter.getView(req); + Repository repo = ServletUtils.getRepository(req); + + RevWalk rw = new RevWalk(repo); + try { + RevObject obj = rw.peel(rw.parseAny(view.getRevision().getId())); + RevTree root; + + switch (obj.getType()) { + case OBJ_COMMIT: + root = ((RevCommit) obj).getTree(); + break; + case OBJ_TREE: + root = (RevTree) obj; + break; + default: + res.setStatus(SC_NOT_FOUND); + return; + } + + TreeWalk tw; + FileType type; + String path = view.getTreePath(); + if (path.isEmpty()) { + tw = new TreeWalk(rw.getObjectReader()); + tw.addTree(root); + tw.setRecursive(false); + type = FileType.TREE; + } else { + tw = TreeWalk.forPath(rw.getObjectReader(), path, root); + if (tw == null) { + res.setStatus(SC_NOT_FOUND); + return; + } + type = FileType.forEntry(tw); + if (type == FileType.TREE) { + tw.enterSubtree(); + tw.setRecursive(false); + } + } + + switch (type) { + case TREE: + showTree(req, res, rw, tw, obj); + break; + case SYMLINK: + showSymlink(req, res, rw, tw); + break; + case REGULAR_FILE: + case EXECUTABLE_FILE: + showFile(req, res, rw, tw); + break; + case GITLINK: + showGitlink(req, res, rw, tw, root); + break; + default: + log.error("Bad file type: %s", type); + res.setStatus(SC_NOT_FOUND); + break; + } + } catch (LargeObjectException e) { + res.setStatus(SC_INTERNAL_SERVER_ERROR); + } finally { + rw.release(); + } + } + + private void showTree(HttpServletRequest req, HttpServletResponse res, RevWalk rw, TreeWalk tw, + ObjectId id) throws IOException { + GitilesView view = ViewFilter.getView(req); + // TODO(sop): Allow caching trees by SHA-1 when no S cookie is sent. + render(req, res, "gitiles.pathDetail", ImmutableMap.of( + "title", !view.getTreePath().isEmpty() ? view.getTreePath() : "/", + "type", FileType.TREE.toString(), + "data", new TreeSoyData(rw, view).toSoyData(id, tw))); + } + + private void showFile(HttpServletRequest req, HttpServletResponse res, RevWalk rw, TreeWalk tw) + throws IOException { + GitilesView view = ViewFilter.getView(req); + // TODO(sop): Allow caching files by SHA-1 when no S cookie is sent. + render(req, res, "gitiles.pathDetail", ImmutableMap.of( + "title", ViewFilter.getView(req).getTreePath(), + "type", FileType.forEntry(tw).toString(), + "data", new BlobSoyData(rw, view).toSoyData(tw.getPathString(), tw.getObjectId(0)))); + } + + private void showSymlink(HttpServletRequest req, HttpServletResponse res, RevWalk rw, + TreeWalk tw) throws IOException { + GitilesView view = ViewFilter.getView(req); + ObjectId id = tw.getObjectId(0); + Map<String, Object> data = Maps.newHashMap(); + + ObjectLoader loader = rw.getObjectReader().open(id, OBJ_BLOB); + String target; + try { + target = RawParseUtils.decode(loader.getCachedBytes(TreeSoyData.MAX_SYMLINK_SIZE)); + } catch (LargeObjectException.OutOfMemory e) { + throw e; + } catch (LargeObjectException e) { + data.put("sha", ObjectId.toString(id)); + data.put("data", null); + data.put("size", Long.toString(loader.getSize())); + render(req, res, "gitiles.pathDetail", ImmutableMap.of( + "title", ViewFilter.getView(req).getTreePath(), + "type", FileType.REGULAR_FILE.toString(), + "data", data)); + return; + } + + String url = resolveTargetUrl( + GitilesView.path() + .copyFrom(view) + .setTreePath(dirname(view.getTreePath())) + .build(), + target); + data.put("title", view.getTreePath()); + data.put("target", target); + if (url != null) { + data.put("targetUrl", url); + } + + // TODO(sop): Allow caching files by SHA-1 when no S cookie is sent. + render(req, res, "gitiles.pathDetail", ImmutableMap.of( + "title", ViewFilter.getView(req).getTreePath(), + "type", FileType.SYMLINK.toString(), + "data", data)); + } + + private static String dirname(String path) { + while (path.charAt(path.length() - 1) == '/') { + path = path.substring(0, path.length() - 1); + } + int lastSlash = path.lastIndexOf('/'); + if (lastSlash > 0) { + return path.substring(0, lastSlash - 1); + } else if (lastSlash == 0) { + return "/"; + } else { + return "."; + } + } + + private void showGitlink(HttpServletRequest req, HttpServletResponse res, RevWalk rw, + TreeWalk tw, RevTree root) throws IOException { + GitilesView view = ViewFilter.getView(req); + SubmoduleWalk sw = SubmoduleWalk.forPath(ServletUtils.getRepository(req), root, + view.getTreePath()); + + String remoteUrl; + try { + remoteUrl = sw.getRemoteUrl(); + } catch (ConfigInvalidException e) { + throw new IOException(e); + } finally { + sw.release(); + } + + Map<String, Object> data = Maps.newHashMap(); + data.put("sha", ObjectId.toString(tw.getObjectId(0))); + data.put("remoteUrl", remoteUrl); + + // TODO(dborowitz): Guess when we can put commit SHAs in the URL. + String httpUrl = resolveHttpUrl(remoteUrl); + if (httpUrl != null) { + data.put("httpUrl", httpUrl); + } + + // TODO(sop): Allow caching links by SHA-1 when no S cookie is sent. + render(req, res, "gitiles.pathDetail", ImmutableMap.of( + "title", view.getTreePath(), + "type", FileType.GITLINK.toString(), + "data", data)); + } + + private static String resolveHttpUrl(String remoteUrl) { + return VERBATIM_SUBMODULE_URL_PATTERN.matcher(remoteUrl).matches() ? remoteUrl : null; + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java b/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java new file mode 100644 index 0000000..42ce635 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/Renderer.java
@@ -0,0 +1,107 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Charsets; +import com.google.common.base.Function; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.template.soy.tofu.SoyTofu; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletResponse; + +/** Renderer for Soy templates used by Gitiles. */ +public abstract class Renderer { + private static final List<String> SOY_FILENAMES = ImmutableList.of( + "Common.soy", + "DiffDetail.soy", + "HostIndex.soy", + "LogDetail.soy", + "ObjectDetail.soy", + "PathDetail.soy", + "RevisionDetail.soy", + "RepositoryIndex.soy"); + + public static final Map<String, String> STATIC_URL_GLOBALS = ImmutableMap.of( + "gitiles.CSS_URL", "gitiles.css", + "gitiles.PRETTIFY_CSS_URL", "prettify/prettify.css", + "gitiles.PRETTIFY_JS_URL", "prettify/prettify.js"); + + protected static final URL toFileURL(String filename) { + if (filename == null) { + return null; + } + try { + return new File(filename).toURI().toURL(); + } catch (MalformedURLException e) { + throw new IllegalArgumentException(e); + } + } + + protected ImmutableList<URL> templates; + protected ImmutableMap<String, String> globals; + + protected Renderer(Function<String, URL> resourceMapper, Map<String, String> globals, + String staticPrefix, URL customTemplates) { + checkNotNull(staticPrefix, "staticPrefix"); + List<URL> allTemplates = Lists.newArrayListWithCapacity(SOY_FILENAMES.size() + 1); + for (String filename : SOY_FILENAMES) { + allTemplates.add(resourceMapper.apply(filename)); + } + if (customTemplates != null) { + allTemplates.add(customTemplates); + } else { + allTemplates.add(resourceMapper.apply("DefaultCustomTemplates.soy")); + } + templates = ImmutableList.copyOf(allTemplates); + + Map<String, String> allGlobals = Maps.newHashMap(); + for (Map.Entry<String, String> e : STATIC_URL_GLOBALS.entrySet()) { + allGlobals.put(e.getKey(), staticPrefix + e.getValue()); + } + allGlobals.putAll(globals); + this.globals = ImmutableMap.copyOf(allGlobals); + } + + public void render(HttpServletResponse res, String templateName) throws IOException { + render(res, templateName, ImmutableMap.<String, Object> of()); + } + + public void render(HttpServletResponse res, String templateName, Map<String, ?> soyData) + throws IOException { + res.setContentType("text/html"); + res.setCharacterEncoding("UTF-8"); + byte[] data = newRenderer(templateName).setData(soyData).render().getBytes(Charsets.UTF_8); + res.setContentLength(data.length); + res.getOutputStream().write(data); + } + + SoyTofu.Renderer newRenderer(String templateName) { + return getTofu().newRenderer(templateName); + } + + protected abstract SoyTofu getTofu(); +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryDescription.java b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryDescription.java new file mode 100644 index 0000000..06ee4da --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryDescription.java
@@ -0,0 +1,25 @@ +// Copyright 2012 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 java.util.Map; + +/** Describes a repository in the {@link HostIndexServlet} JSON output. */ +public class RepositoryDescription { + public String name; + public String cloneUrl; + public String description; + public Map<String, String> branches; +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java new file mode 100644 index 0000000..325068a --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
@@ -0,0 +1,80 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; + +import org.eclipse.jgit.http.server.ServletUtils; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.RefComparator; +import org.eclipse.jgit.lib.RefDatabase; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** Serves the index page for a repository, if accessed directly by a browser. */ +public class RepositoryIndexServlet extends BaseServlet { + private final GitilesAccess.Factory accessFactory; + + public RepositoryIndexServlet(Renderer renderer, GitilesAccess.Factory accessFactory) { + super(renderer); + this.accessFactory = checkNotNull(accessFactory, "accessFactory"); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { + render(req, res, "gitiles.repositoryIndex", buildData(req)); + } + + @VisibleForTesting + Map<String, ?> buildData(HttpServletRequest req) throws IOException { + RepositoryDescription desc = accessFactory.forRequest(req).getRepositoryDescription(); + return ImmutableMap.of( + "cloneUrl", desc.cloneUrl, + "description", Strings.nullToEmpty(desc.description), + "branches", getRefs(req, Constants.R_HEADS), + "tags", getRefs(req, Constants.R_TAGS)); + } + + private List<Map<String, String>> getRefs(HttpServletRequest req, String prefix) + throws IOException { + RefDatabase refdb = ServletUtils.getRepository(req).getRefDatabase(); + String repoName = ViewFilter.getView(req).getRepositoryName(); + Collection<Ref> refs = RefComparator.sort(refdb.getRefs(prefix).values()); + List<Map<String, String>> result = Lists.newArrayListWithCapacity(refs.size()); + + for (Ref ref : refs) { + String name = ref.getName().substring(prefix.length()); + boolean needPrefix = !ref.getName().equals(refdb.getRef(name).getName()); + result.add(ImmutableMap.of( + "url", GitilesView.log().copyFrom(req).setRevision( + Revision.unpeeled(needPrefix ? ref.getName() : name, ref.getObjectId())).toUrl(), + "name", name)); + } + + return result; + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/Revision.java b/gitiles-servlet/src/main/java/com/google/gitiles/Revision.java new file mode 100644 index 0000000..05778e0 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/Revision.java
@@ -0,0 +1,138 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkArgument; +import static org.eclipse.jgit.lib.Constants.OBJ_BAD; +import static org.eclipse.jgit.lib.Constants.OBJ_TAG; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Objects; + +import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.lib.AbbreviatedObjectId; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.revwalk.RevObject; +import org.eclipse.jgit.revwalk.RevWalk; + +import java.io.IOException; + +/** + * Object encapsulating a single revision as seen by Gitiles. + * <p> + * A single revision consists of a name, an ID, and a type. Name parsing is done + * once per request by {@link RevisionParser}. + */ +public class Revision { + /** Sentinel indicating a missing or empty revision. */ + public static final Revision NULL = peeled("", ObjectId.zeroId(), OBJ_BAD); + + /** Common default branch given to clients. */ + public static final Revision HEAD = named("HEAD"); + + private final String name; + private final ObjectId id; + private final int type; + private final ObjectId peeledId; + private final int peeledType; + + public static Revision peeled(String name, RevObject obj) { + return peeled(name, obj, obj.getType()); + } + + public static Revision unpeeled(String name, ObjectId id) { + return peeled(name, id, OBJ_BAD); + } + + public static Revision named(String name) { + return peeled(name, null, OBJ_BAD); + } + + public static Revision peel(String name, ObjectId id, RevWalk walk) + throws MissingObjectException, IOException { + RevObject obj = walk.parseAny(id); + RevObject peeled = walk.peel(obj); + return new Revision(name, obj, obj.getType(), peeled, peeled.getType()); + } + + private static Revision peeled(String name, ObjectId id, int type) { + checkArgument(type != OBJ_TAG, "expected non-tag for %s/%s", name, id); + return new Revision(name, id, type, id, type); + } + + @VisibleForTesting + Revision(String name, ObjectId id, int type, ObjectId peeledId, int peeledType) { + this.name = name; + this.id = id; + this.type = type; + this.peeledId = peeledId; + this.peeledType = peeledType; + } + + public String getName() { + return name; + } + + public int getType() { + return type; + } + + public ObjectId getId() { + return id; + } + + public ObjectId getPeeledId() { + return peeledId; + } + + public int getPeeledType() { + return peeledType; + } + + public boolean nameIsId() { + return AbbreviatedObjectId.isId(name) + && (AbbreviatedObjectId.fromString(name).prefixCompare(id) == 0); + } + + @Override + public boolean equals(Object o) { + if (o instanceof Revision) { + Revision r = (Revision) o; + return Objects.equal(name, r.name) + && Objects.equal(id, r.id) + && Objects.equal(type, r.type) + && Objects.equal(peeledId, r.peeledId) + && Objects.equal(peeledType, r.peeledType); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(name, id, type, peeledId, peeledType); + } + + @Override + public String toString() { + return Objects.toStringHelper(this) + .omitNullValues() + .add("name", name) + .add("id", id) + .add("type", type) + .add("peeledId", peeledId) + .add("peeledType", peeledType) + .toString(); + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RevisionParser.java b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionParser.java new file mode 100644 index 0000000..3e0acfa --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionParser.java
@@ -0,0 +1,214 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.CharMatcher; +import com.google.common.base.Objects; +import com.google.common.base.Splitter; + +import org.eclipse.jgit.errors.IncorrectObjectTypeException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; + +import java.io.IOException; + +/** Object to parse revisions out of Gitiles paths. */ +class RevisionParser { + static final Splitter PATH_SPLITTER = Splitter.on('/'); + private static final Splitter OPERATOR_SPLITTER = Splitter.on(CharMatcher.anyOf("^~")); + + static class Result { + private final Revision revision; + private final Revision oldRevision; + private final int pathStart; + + @VisibleForTesting + Result(Revision revision) { + this(revision, null, revision.getName().length()); + } + + @VisibleForTesting + Result(Revision revision, Revision oldRevision, int pathStart) { + this.revision = revision; + this.oldRevision = oldRevision; + this.pathStart = pathStart; + } + + public Revision getRevision() { + return revision; + } + + public Revision getOldRevision() { + return oldRevision; + } + + @Override + public boolean equals(Object o) { + if (o instanceof Result) { + Result r = (Result) o; + return Objects.equal(revision, r.revision) + && Objects.equal(oldRevision, r.oldRevision) + && Objects.equal(pathStart, r.pathStart); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(revision, oldRevision, pathStart); + } + + @Override + public String toString() { + return Objects.toStringHelper(this) + .omitNullValues() + .add("revision", revision) + .add("oldRevision", oldRevision) + .add("pathStart", pathStart) + .toString(); + } + + int getPathStart() { + return pathStart; + } + } + + private final Repository repo; + private final GitilesAccess access; + private final VisibilityCache cache; + + RevisionParser(Repository repo, GitilesAccess access, VisibilityCache cache) { + this.repo = checkNotNull(repo, "repo"); + this.access = checkNotNull(access, "access"); + this.cache = checkNotNull(cache, "cache"); + } + + Result parse(String path) throws IOException { + RevWalk walk = new RevWalk(repo); + try { + Revision oldRevision = null; + + StringBuilder b = new StringBuilder(); + boolean first = true; + for (String part : PATH_SPLITTER.split(path)) { + if (part.isEmpty()) { + return null; // No valid revision contains empty segments. + } + if (!first) { + b.append('/'); + } + + if (oldRevision == null) { + int dots = part.indexOf(".."); + int firstParent = part.indexOf("^!"); + if (dots == 0 || firstParent == 0) { + return null; + } else if (dots > 0) { + b.append(part.substring(0, dots)); + String oldName = b.toString(); + if (!isValidRevision(oldName)) { + return null; + } else { + ObjectId old = repo.resolve(oldName); + if (old == null) { + return null; + } + oldRevision = Revision.peel(oldName, old, walk); + } + part = part.substring(dots + 2); + b = new StringBuilder(); + } else if (firstParent > 0) { + if (firstParent != part.length() - 2) { + return null; + } + b.append(part.substring(0, part.length() - 2)); + String name = b.toString(); + if (!isValidRevision(name)) { + return null; + } + ObjectId id = repo.resolve(name); + if (id == null) { + return null; + } + RevCommit c; + try { + c = walk.parseCommit(id); + } catch (IncorrectObjectTypeException e) { + return null; // Not a commit, ^! is invalid. + } + if (c.getParentCount() > 0) { + oldRevision = Revision.peeled(name + "^", c.getParent(0)); + } else { + oldRevision = Revision.NULL; + } + Result result = new Result(Revision.peeled(name, c), oldRevision, name.length() + 2); + return isVisible(walk, result) ? result : null; + } + } + b.append(part); + + String name = b.toString(); + if (!isValidRevision(name)) { + return null; + } + ObjectId id = repo.resolve(name); + if (id != null) { + int pathStart; + if (oldRevision == null) { + pathStart = name.length(); // foo + } else { + // foo..bar (foo may be empty) + pathStart = oldRevision.getName().length() + 2 + name.length(); + } + Result result = new Result(Revision.peel(name, id, walk), oldRevision, pathStart); + return isVisible(walk, result) ? result : null; + } + first = false; + } + return null; + } finally { + walk.release(); + } + } + + private static boolean isValidRevision(String revision) { + // Disallow some uncommon but valid revision expressions that either we + // don't support or we represent differently in our URLs. + return revision.indexOf(':') < 0 + && revision.indexOf("^{") < 0 + && revision.indexOf('@') < 0; + } + + private boolean isVisible(RevWalk walk, Result result) throws IOException { + String maybeRef = OPERATOR_SPLITTER.split(result.getRevision().getName()).iterator().next(); + if (repo.getRef(maybeRef) != null) { + // Name contains a visible ref; skip expensive reachability check. + return true; + } + if (!cache.isVisible(repo, walk, access, result.getRevision().getId())) { + return false; + } + if (result.getOldRevision() != null && result.getOldRevision() != Revision.NULL) { + return cache.isVisible(repo, walk, access, result.getOldRevision().getId()); + } else { + return true; + } + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java new file mode 100644 index 0000000..66a7086 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/RevisionServlet.java
@@ -0,0 +1,136 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkNotNull; +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; +import static org.eclipse.jgit.lib.Constants.OBJ_BLOB; +import static org.eclipse.jgit.lib.Constants.OBJ_COMMIT; +import static org.eclipse.jgit.lib.Constants.OBJ_TAG; +import static org.eclipse.jgit.lib.Constants.OBJ_TREE; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.google.gitiles.CommitSoyData.KeySet; + +import org.eclipse.jgit.errors.IncorrectObjectTypeException; +import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.http.server.ServletUtils; +import org.eclipse.jgit.lib.Constants; +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.RevTag; +import org.eclipse.jgit.revwalk.RevWalk; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** Serves an HTML page with detailed information about a ref. */ +public class RevisionServlet extends BaseServlet { + private static final Logger log = LoggerFactory.getLogger(RevisionServlet.class); + + private final Linkifier linkifier; + + public RevisionServlet(Renderer renderer, Linkifier linkifier) { + super(renderer); + this.linkifier = checkNotNull(linkifier, "linkifier"); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse res) throws IOException { + GitilesView view = ViewFilter.getView(req); + Repository repo = ServletUtils.getRepository(req); + + RevWalk walk = new RevWalk(repo); + try { + List<RevObject> objects = listObjects(walk, view.getRevision().getId()); + List<Map<String, ?>> soyObjects = Lists.newArrayListWithCapacity(objects.size()); + boolean hasBlob = false; + + // TODO(sop): Allow caching commits by SHA-1 when no S cookie is sent. + for (RevObject obj : objects) { + try { + switch (obj.getType()) { + case OBJ_COMMIT: + soyObjects.add(ImmutableMap.of( + "type", Constants.TYPE_COMMIT, + "data", new CommitSoyData(linkifier, req, repo, walk, view) + .toSoyData((RevCommit) obj, KeySet.DETAIL_DIFF_TREE))); + break; + case OBJ_TREE: + soyObjects.add(ImmutableMap.of( + "type", Constants.TYPE_TREE, + "data", new TreeSoyData(walk, view).toSoyData(obj))); + break; + case OBJ_BLOB: + soyObjects.add(ImmutableMap.of( + "type", Constants.TYPE_BLOB, + "data", new BlobSoyData(walk, view).toSoyData(obj))); + hasBlob = true; + break; + case OBJ_TAG: + soyObjects.add(ImmutableMap.of( + "type", Constants.TYPE_TAG, + "data", new TagSoyData(linkifier, req).toSoyData((RevTag) obj))); + break; + default: + log.warn("Bad object type for %s: %s", ObjectId.toString(obj.getId()), obj.getType()); + res.setStatus(SC_NOT_FOUND); + return; + } + } catch (MissingObjectException e) { + log.warn("Missing object " + ObjectId.toString(obj.getId()), e); + res.setStatus(SC_NOT_FOUND); + return; + } catch (IncorrectObjectTypeException e) { + log.warn("Incorrect object type for " + ObjectId.toString(obj.getId()), e); + res.setStatus(SC_NOT_FOUND); + return; + } + } + + render(req, res, "gitiles.revisionDetail", ImmutableMap.of( + "title", view.getRevision().getName(), + "objects", soyObjects, + "hasBlob", hasBlob)); + } finally { + walk.release(); + } + } + + // TODO(dborowitz): Extract this. + static List<RevObject> listObjects(RevWalk walk, ObjectId id) + throws MissingObjectException, IOException { + List<RevObject> objects = Lists.newArrayListWithExpectedSize(1); + while (true) { + RevObject cur = walk.parseAny(id); + objects.add(cur); + if (cur.getType() == Constants.OBJ_TAG) { + id = ((RevTag) cur).getObject(); + } else { + break; + } + } + return objects; + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/TagSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/TagSoyData.java new file mode 100644 index 0000000..9ddf1e5 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/TagSoyData.java
@@ -0,0 +1,50 @@ +// Copyright 2012 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 com.google.common.collect.Maps; + +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.revwalk.RevTag; +import org.eclipse.jgit.util.GitDateFormatter; +import org.eclipse.jgit.util.GitDateFormatter.Format; + +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; + +/** Soy data converter for git tags. */ +public class TagSoyData { + private final Linkifier linkifier; + private final HttpServletRequest req; + private final GitDateFormatter dateFormatter; + + public TagSoyData(Linkifier linkifier, HttpServletRequest req) { + this.linkifier = linkifier; + this.req = req; + this.dateFormatter = new GitDateFormatter(Format.DEFAULT); + } + + public Map<String, Object> toSoyData(RevTag tag) { + Map<String, Object> data = Maps.newHashMapWithExpectedSize(4); + data.put("sha", ObjectId.toString(tag)); + if (tag.getTaggerIdent() != null) { + data.put("tagger", CommitSoyData.toSoyData(tag.getTaggerIdent(), dateFormatter)); + } + data.put("object", ObjectId.toString(tag.getObject())); + data.put("message", linkifier.linkify(req, tag.getFullMessage())); + return data; + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java new file mode 100644 index 0000000..6cbf541 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
@@ -0,0 +1,160 @@ +// Copyright 2012 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 com.google.gitiles.RevisionParser.PATH_SPLITTER; +import static org.eclipse.jgit.lib.Constants.OBJ_COMMIT; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Charsets; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.io.Files; +import com.google.gitiles.PathServlet.FileType; + +import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.TreeWalk; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.StringTokenizer; + +/** Soy data converter for git trees. */ +public class TreeSoyData { + /** + * Number of characters to display for a symlink target. Targets longer than + * this are abbreviated for display in a tree listing. + */ + private static final int MAX_SYMLINK_TARGET_LENGTH = 72; + + /** + * Maximum number of bytes to load from a blob that claims to be a symlink. If + * the blob is larger than this byte limit it will be displayed as a binary + * file instead of as a symlink. + */ + static final int MAX_SYMLINK_SIZE = 16 << 10; + + static String resolveTargetUrl(GitilesView view, String target) { + if (target.startsWith("/")) { + return null; + } + + // simplifyPath() normalizes "a/../../" to "a", so manually check whether + // the path leads above the git root. + int depth = new StringTokenizer(view.getTreePath(), "/").countTokens(); + for (String part : PATH_SPLITTER.split(target)) { + if (part.equals("..")) { + depth--; + if (depth < 0) { + return null; + } + } else if (!part.isEmpty() && !part.equals(".")) { + depth++; + } + } + + String path = Files.simplifyPath(view.getTreePath() + "/" + target); + return GitilesView.path() + .copyFrom(view) + .setTreePath(!path.equals(".") ? path : "") + .toUrl(); + } + + @VisibleForTesting + static String getTargetDisplayName(String target) { + if (target.length() <= MAX_SYMLINK_TARGET_LENGTH) { + return target; + } else { + int lastSlash = target.lastIndexOf('/'); + // TODO(dborowitz): Doesn't abbreviate a long last path component. + return lastSlash >= 0 ? "..." + target.substring(lastSlash) : target; + } + } + + private final RevWalk rw; + private final GitilesView view; + + public TreeSoyData(RevWalk rw, GitilesView view) { + this.rw = rw; + this.view = view; + } + + public Map<String, Object> toSoyData(ObjectId treeId, TreeWalk tw) throws MissingObjectException, + IOException { + List<Object> entries = Lists.newArrayList(); + GitilesView.Builder urlBuilder = GitilesView.path().copyFrom(view); + while (tw.next()) { + FileType type = FileType.forEntry(tw); + String name = tw.getNameString(); + + switch (view.getType()) { + case PATH: + urlBuilder.setTreePath(view.getTreePath() + "/" + name); + break; + case REVISION: + // Got here from a tag pointing at a tree. + urlBuilder.setTreePath(name); + break; + default: + throw new IllegalStateException(String.format( + "Cannot render TreeSoyData from %s view", view.getType())); + } + + String url = urlBuilder.toUrl(); + if (type == FileType.TREE) { + name += "/"; + url += "/"; + } + Map<String, String> entry = Maps.newHashMapWithExpectedSize(4); + entry.put("type", type.toString()); + entry.put("name", name); + entry.put("url", url); + if (type == FileType.SYMLINK) { + String target = new String( + rw.getObjectReader().open(tw.getObjectId(0)).getCachedBytes(), + Charsets.UTF_8); + // TODO(dborowitz): Merge Shawn's changes before copying these methods + // in. + entry.put("targetName", getTargetDisplayName(target)); + String targetUrl = resolveTargetUrl(view, target); + if (targetUrl != null) { + entry.put("targetUrl", targetUrl); + } + } + entries.add(entry); + } + + Map<String, Object> data = Maps.newHashMapWithExpectedSize(3); + data.put("sha", treeId.name()); + data.put("entries", entries); + + if (view.getType() == GitilesView.Type.PATH + && view.getRevision().getPeeledType() == OBJ_COMMIT) { + data.put("logUrl", GitilesView.log().copyFrom(view).toUrl()); + } + + return data; + } + + public Map<String, Object> toSoyData(ObjectId treeId) throws MissingObjectException, IOException { + TreeWalk tw = new TreeWalk(rw.getObjectReader()); + tw.addTree(treeId); + tw.setRecursive(false); + return toSoyData(treeId, tw); + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java new file mode 100644 index 0000000..8874b1d --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/ViewFilter.java
@@ -0,0 +1,160 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; + +import org.eclipse.jgit.http.server.ServletUtils; +import org.eclipse.jgit.http.server.glue.WrappedRequest; +import org.eclipse.jgit.lib.Constants; + +import java.io.IOException; +import java.util.Map; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** Filter to parse URLs and convert them to {@link GitilesView}s. */ +public class ViewFilter extends AbstractHttpFilter { + // TODO(dborowitz): Make this public in JGit (or implement getRegexGroup + // upstream). + private static final String REGEX_GROUPS_ATTRIBUTE = + "org.eclipse.jgit.http.server.glue.MetaServlet.serveRegex"; + + private static final String VIEW_ATTIRBUTE = ViewFilter.class.getName() + "/View"; + + private static final String CMD_AUTO = "+"; + private static final String CMD_DIFF = "+diff"; + private static final String CMD_LOG = "+log"; + private static final String CMD_SHOW = "+show"; + + public static GitilesView getView(HttpServletRequest req) { + return (GitilesView) req.getAttribute(VIEW_ATTIRBUTE); + } + + static String getRegexGroup(HttpServletRequest req, int groupId) { + WrappedRequest[] groups = (WrappedRequest[]) req.getAttribute(REGEX_GROUPS_ATTRIBUTE); + return checkNotNull(groups)[groupId].getPathInfo(); + } + + static void setView(HttpServletRequest req, GitilesView view) { + req.setAttribute(VIEW_ATTIRBUTE, view); + } + + static String trimLeadingSlash(String str) { + checkArgument(str.startsWith("/"), "expected string starting with a slash: %s", str); + return str.substring(1); + } + + private final GitilesUrls urls; + private final GitilesAccess.Factory accessFactory; + private final VisibilityCache visibilityCache; + + public ViewFilter(GitilesAccess.Factory accessFactory, GitilesUrls urls, + VisibilityCache visibilityCache) { + this.urls = checkNotNull(urls, "urls"); + this.accessFactory = checkNotNull(accessFactory, "accessFactory"); + this.visibilityCache = checkNotNull(visibilityCache, "visibilityCache"); + } + + @Override + public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws IOException, ServletException { + GitilesView.Builder view = parse(req); + if (view == null) { + res.setStatus(SC_NOT_FOUND); + return; + } + @SuppressWarnings("unchecked") + Map<String, String[]> params = req.getParameterMap(); + view.setHostName(urls.getHostName(req)) + .setServletPath(req.getContextPath() + req.getServletPath()) + .putAllParams(params); + setView(req, view.build()); + try { + chain.doFilter(req, res); + } finally { + req.removeAttribute(VIEW_ATTIRBUTE); + } + } + + private GitilesView.Builder parse(HttpServletRequest req) throws IOException { + String repoName = trimLeadingSlash(getRegexGroup(req, 1)); + String command = getRegexGroup(req, 2); + String path = getRegexGroup(req, 3); + + // Non-path cases. + if (repoName.isEmpty()) { + return GitilesView.hostIndex(); + } else if (command.isEmpty()) { + return GitilesView.repositoryIndex().setRepositoryName(repoName); + } else if (path.isEmpty()) { + return null; // Command but no path. + } + + path = trimLeadingSlash(path); + RevisionParser revParser = new RevisionParser( + ServletUtils.getRepository(req), accessFactory.forRequest(req), visibilityCache); + RevisionParser.Result result = revParser.parse(path); + if (result == null) { + return null; + } + path = path.substring(result.getPathStart()); + + command = getCommand(command, result, path); + GitilesView.Builder view; + if (CMD_LOG.equals(command)) { + view = GitilesView.log().setTreePath(path); + } else if (CMD_SHOW.equals(command)) { + if (path.isEmpty()) { + view = GitilesView.revision(); + } else { + view = GitilesView.path().setTreePath(path); + } + } else if (CMD_DIFF.equals(command)) { + view = GitilesView.diff().setTreePath(path); + } else { + return null; // Bad command. + } + if (result.getOldRevision() != null) { // May be NULL. + view.setOldRevision(result.getOldRevision()); + } + view.setRepositoryName(repoName) + .setRevision(result.getRevision()); + return view; + } + + private String getCommand(String command, RevisionParser.Result result, String path) { + // Note: if you change the mapping for +, make sure to change + // GitilesView.toUrl() correspondingly. + if (!CMD_AUTO.equals(command)) { + return command; + } else if (result.getOldRevision() != null) { + return CMD_DIFF; + } + Revision rev = result.getRevision(); + if (rev.getPeeledType() != Constants.OBJ_COMMIT + || !path.isEmpty() + || result.getRevision().nameIsId()) { + return CMD_SHOW; + } else { + return CMD_LOG; + } + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/VisibilityCache.java b/gitiles-servlet/src/main/java/com/google/gitiles/VisibilityCache.java new file mode 100644 index 0000000..22969d1 --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/VisibilityCache.java
@@ -0,0 +1,189 @@ +// Copyright 2012 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 com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Predicates.not; +import static com.google.common.collect.Collections2.filter; + +import com.google.common.base.Objects; +import com.google.common.base.Predicate; +import com.google.common.base.Throwables; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; + +import org.eclipse.jgit.errors.IncorrectObjectTypeException; +import org.eclipse.jgit.errors.MissingObjectException; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.RefDatabase; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevSort; +import org.eclipse.jgit.revwalk.RevWalk; + +import java.io.IOException; +import java.util.Collection; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +/** Cache of per-user object visibility. */ +public class VisibilityCache { + private static class Key { + private final Object user; + private final String repositoryName; + private final ObjectId objectId; + + private Key(Object user, String repositoryName, ObjectId objectId) { + this.user = checkNotNull(user, "user"); + this.repositoryName = checkNotNull(repositoryName, "repositoryName"); + this.objectId = checkNotNull(objectId, "objectId"); + } + + @Override + public boolean equals(Object o) { + if (o instanceof Key) { + Key k = (Key) o; + return Objects.equal(user, k.user) + && Objects.equal(repositoryName, k.repositoryName) + && Objects.equal(objectId, k.objectId); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(user, repositoryName, objectId); + } + + @Override + public String toString() { + return Objects.toStringHelper(this) + .add("user", user) + .add("repositoryName", repositoryName) + .add("objectId", objectId) + .toString(); + } + } + + private final Cache<Key, Boolean> cache; + private final boolean topoSort; + + public static CacheBuilder<Object, Object> newBuilder() { + return CacheBuilder.newBuilder() + .maximumSize(1 << 10) + .expireAfterWrite(30, TimeUnit.MINUTES); + } + + public VisibilityCache(boolean topoSort) { + this(topoSort, newBuilder()); + } + + public VisibilityCache(boolean topoSort, CacheBuilder<Object, Object> builder) { + this.cache = builder.build(); + this.topoSort = topoSort; + } + + public Cache<?, Boolean> getCache() { + return cache; + } + + boolean isVisible(final Repository repo, final RevWalk walk, GitilesAccess access, + final ObjectId id) throws IOException { + try { + return cache.get( + new Key(access.getUserKey(), access.getRepositoryName(), id), + new Callable<Boolean>() { + @Override + public Boolean call() throws IOException { + return isVisible(repo, walk, id); + } + }); + } catch (ExecutionException e) { + Throwables.propagateIfInstanceOf(e.getCause(), IOException.class); + throw new IOException(e); + } + } + + private boolean isVisible(Repository repo, RevWalk walk, ObjectId id) throws IOException { + RevCommit commit; + try { + commit = walk.parseCommit(id); + } catch (IncorrectObjectTypeException e) { + return false; + } + + // If any reference directly points at the requested object, permit display. + // Common for displays of pending patch sets in Gerrit Code Review, or + // bookmarks to the commit a tag points at. + Collection<Ref> allRefs = repo.getRefDatabase().getRefs(RefDatabase.ALL).values(); + for (Ref ref : allRefs) { + ref = repo.getRefDatabase().peel(ref); + if (id.equals(ref.getObjectId()) || id.equals(ref.getPeeledObjectId())) { + return true; + } + } + + // Check heads first under the assumption that most requests are for refs + // close to a head. Tags tend to be much further back in history and just + // clutter up the priority queue in the common case. + return isReachableFrom(walk, commit, filter(allRefs, refStartsWith(Constants.R_HEADS))) + || isReachableFrom(walk, commit, filter(allRefs, refStartsWith(Constants.R_TAGS))) + || isReachableFrom(walk, commit, filter(allRefs, not(refStartsWith("refs/changes/")))); + } + + private static Predicate<Ref> refStartsWith(final String prefix) { + return new Predicate<Ref>() { + @Override + public boolean apply(Ref ref) { + return ref.getName().startsWith(prefix); + } + }; + } + + private boolean isReachableFrom(RevWalk walk, RevCommit commit, Collection<Ref> refs) + throws IOException { + walk.reset(); + if (topoSort) { + walk.sort(RevSort.TOPO); + } + walk.markStart(commit); + for (Ref ref : refs) { + if (ref.getPeeledObjectId() != null) { + markUninteresting(walk, ref.getPeeledObjectId()); + } else { + markUninteresting(walk, ref.getObjectId()); + } + } + // If the commit is reachable from any branch head, it will appear to be + // uninteresting to the RevWalk and no output will be produced. + return walk.next() == null; + } + + private static void markUninteresting(RevWalk walk, ObjectId id) throws IOException { + if (id == null) { + return; + } + try { + walk.markUninteresting(walk.parseCommit(id)); + } catch (IncorrectObjectTypeException e) { + // Do nothing, doesn't affect reachability. + } catch (MissingObjectException e) { + // Do nothing, doesn't affect reachability. + } + } +}