blob: c2fab74ecd6af49fa61306de58eb9e7faf45bb2a [file] [log] [blame]
// Copyright (C) 2014 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.googlesource.gerrit.plugins.xdocs;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.Weigher;
import com.google.common.collect.Maps;
import com.google.gerrit.extensions.annotations.PluginName;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.httpd.resources.Resource;
import com.google.gerrit.httpd.resources.SmallResource;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.server.cache.CacheModule;
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.gerrit.server.config.PluginConfigFactory;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import com.googlesource.gerrit.plugins.xdocs.formatter.Formatter;
import com.googlesource.gerrit.plugins.xdocs.formatter.Formatters;
import com.googlesource.gerrit.plugins.xdocs.formatter.Formatters.FormatterProvider;
import com.googlesource.gerrit.plugins.xdocs.formatter.StreamFormatter;
import com.googlesource.gerrit.plugins.xdocs.formatter.StringFormatter;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.diff.RawText;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.treewalk.filter.PathFilter;
import org.outerj.daisy.diff.HtmlCleaner;
import org.outerj.daisy.diff.XslFilter;
import org.outerj.daisy.diff.html.HTMLDiffer;
import org.outerj.daisy.diff.html.HtmlSaxDiffOutput;
import org.outerj.daisy.diff.html.TextNodeComparator;
import org.outerj.daisy.diff.html.dom.DomTreeBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.ContentHandler;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.AttributesImpl;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.sax.SAXTransformerFactory;
import javax.xml.transform.sax.TransformerHandler;
import javax.xml.transform.stream.StreamResult;
@Singleton
public class XDocLoader extends CacheLoader<String, Resource> {
private static final Logger log = LoggerFactory.getLogger(XDocLoader.class);
private static final String DEFAULT_HOST = "review.example.com";
private final GitRepositoryManager repoManager;
private final Provider<String> webUrl;
private final String pluginName;
private final PluginConfigFactory cfgFactory;
private final Formatters formatters;
@Inject
XDocLoader(GitRepositoryManager repoManager,
@CanonicalWebUrl Provider<String> webUrl,
@PluginName String pluginName,
PluginConfigFactory cfgFactory,
Formatters formatters) {
this.repoManager = repoManager;
this.webUrl = webUrl;
this.pluginName = pluginName;
this.cfgFactory = cfgFactory;
this.formatters = formatters;
}
@Override
public Resource load(String strKey) throws Exception {
XDocResourceKey key = XDocResourceKey.fromString(strKey);
try (Repository repo = repoManager.openRepository(key.getProject())) {
FormatterProvider formatter = getFormatter(key.getFormatter());
try (RevWalk rw = new RevWalk(repo)) {
String html = null;
if (key.getRevId() != null) {
html = loadHtml(formatter, repo, rw, key, key.getRevId());
}
if (key.getDiffMode() != DiffMode.NO_DIFF) {
String htmlB =
loadHtml(formatter, repo, rw, key, checkRevId(key.getRevIdB()));
if (html == null && htmlB == null) {
throw new ResourceNotFoundException();
}
html = diffHtml(html, htmlB, key.getDiffMode());
} else {
if (html == null) {
throw new ResourceNotFoundException();
}
}
RevCommit commit = rw.parseCommit(
MoreObjects.firstNonNull(key.getRevIdB(), key.getRevId()));
return getAsHtmlResource(html, commit.getCommitTime());
}
} catch (ResourceNotFoundException e) {
return Resource.NOT_FOUND;
} catch (MethodNotAllowedException e) {
return Resources.METHOD_NOT_ALLOWED;
}
}
private FormatterProvider getFormatter(String formatterName)
throws ResourceNotFoundException {
FormatterProvider formatter = formatters.getByName(formatterName);
if (formatter == null) {
throw new ResourceNotFoundException();
}
return formatter;
}
private static ObjectId checkRevId(ObjectId revId)
throws ResourceNotFoundException {
if (revId == null) {
throw new ResourceNotFoundException();
}
return revId;
}
private String loadHtml(FormatterProvider formatter, Repository repo,
RevWalk rw, XDocResourceKey key, ObjectId revId) throws IOException,
ResourceNotFoundException, MethodNotAllowedException, GitAPIException {
RevCommit commit = rw.parseCommit(revId);
RevTree tree = commit.getTree();
try (TreeWalk tw = new TreeWalk(repo)) {
tw.addTree(tree);
tw.setRecursive(true);
tw.setFilter(PathFilter.create(key.getResource()));
if (!tw.next()) {
return null;
}
ObjectId objectId = tw.getObjectId(0);
ObjectLoader loader = repo.open(objectId);
return getHtml(formatter, repo, loader, key.getProject(),
key.getResource(), revId);
}
}
private String getHtml(FormatterProvider formatter, Repository repo,
ObjectLoader loader, Project.NameKey project, String path, ObjectId revId)
throws MethodNotAllowedException, IOException, GitAPIException,
ResourceNotFoundException {
Formatter f = formatter.get();
if (f instanceof StringFormatter) {
return getHtml(formatter.getName(), (StringFormatter) f, repo, loader,
project, path, revId);
} else if (f instanceof StreamFormatter) {
return getHtml(formatter.getName(), (StreamFormatter) f, repo, loader,
project, path, revId);
} else {
log.error(String.format("Unsupported formatter: %s", formatter.getName()));
throw new ResourceNotFoundException();
}
}
private String getHtml(String formatterName, StringFormatter f,
Repository repo, ObjectLoader loader, Project.NameKey project,
String path, ObjectId revId) throws MethodNotAllowedException,
IOException, GitAPIException {
byte[] bytes = loader.getBytes(Integer.MAX_VALUE);
boolean isBinary = RawText.isBinary(bytes);
if (formatterName.equals(Formatters.RAW_FORMATTER) && isBinary) {
throw new MethodNotAllowedException();
}
String raw = new String(bytes, UTF_8);
String abbrRevId = getAbbrRevId(repo, revId);
if (!isBinary) {
raw = replaceMacros(repo, project, revId, abbrRevId, raw);
}
return f.format(project.get(), path, revId.getName(), abbrRevId,
getFormatterConfig(formatterName), raw);
}
private String getHtml(String formatterName, StreamFormatter f,
Repository repo, ObjectLoader loader, Project.NameKey project,
String path, ObjectId revId) throws IOException {
try (InputStream raw = loader.openStream()) {
return f.format(project.get(), path, revId.getName(),
getAbbrRevId(repo, revId), getFormatterConfig(formatterName), raw);
}
}
private String diffHtml(String htmlA, String htmlB, DiffMode diffMode)
throws IOException, TransformerConfigurationException, SAXException,
ResourceNotFoundException {
ByteArrayOutputStream htmlDiff = new ByteArrayOutputStream();
SAXTransformerFactory tf =
(SAXTransformerFactory) TransformerFactory.newInstance();
TransformerHandler result = tf.newTransformerHandler();
result.setResult(new StreamResult(htmlDiff));
String htmlHeader = "com/googlesource/gerrit/plugins/xdocs/diff/htmlheader-";
switch (diffMode) {
case SIDEBYSIDE_A:
htmlHeader += "sidebyside-a.xsl";
break;
case SIDEBYSIDE_B:
htmlHeader += "sidebyside-b.xsl";
break;
case UNIFIED:
htmlHeader += "unified.xsl";
break;
default:
log.error(String.format("Unsupported diff mode: %s", diffMode.name()));
throw new ResourceNotFoundException();
}
ContentHandler postProcess = new XslFilter().xsl(result, htmlHeader);
postProcess.startDocument();
postProcess.startElement("", "diffreport", "diffreport",
new AttributesImpl());
postProcess.startElement("", "diff", "diff",
new AttributesImpl());
HtmlSaxDiffOutput output = new HtmlSaxDiffOutput(postProcess, "diff");
HTMLDiffer differ = new HTMLDiffer(output);
differ.diff(getComparator(htmlA), getComparator(htmlB));
postProcess.endElement("", "diff", "diff");
postProcess.endElement("", "diffreport", "diffreport");
postProcess.endDocument();
return fixStyles(htmlDiff.toString(UTF_8.name()));
}
/**
* The daisydiff formatting may make inlined styles unparsable. Fix it:
* <ul>
* <li>Remove span element to highlight addition/deletion inside style elements.</li>
* <li>Replace '&gt;' with '>'.</li>
* </ul>
*/
private String fixStyles(String html) {
Matcher m =
Pattern.compile("(<style[a-zA-Z -=/\"]+>\n)<[a-zA-Z -=\"]+>(.*)</[a-z]+>(\n</style>)")
.matcher(html);
StringBuffer sb = new StringBuffer();
while (m.find()) {
m.appendReplacement(sb, m.group(1) + m.group(2).replaceAll("&gt;", ">")
+ m.group(3));
}
m.appendTail(sb);
return sb.toString();
}
private TextNodeComparator getComparator(String html) throws IOException,
SAXException {
InputSource source =
new InputSource(new ByteArrayInputStream(
Strings.nullToEmpty(html).getBytes(UTF_8)));
DomTreeBuilder handler = new DomTreeBuilder();
new HtmlCleaner().cleanAndParse(source, handler);
return new TextNodeComparator(handler, Locale.US);
}
private ConfigSection getFormatterConfig(String formatterName) {
XDocGlobalConfig cfg =
new XDocGlobalConfig(cfgFactory.getGlobalPluginConfig(pluginName));
return cfg.getFormatterConfig(formatterName);
}
private static String getAbbrRevId(Repository repo, ObjectId revId)
throws IOException {
try (ObjectReader reader = repo.newObjectReader()) {
return reader.abbreviate(revId).name();
}
}
private String replaceMacros(Repository repo, Project.NameKey project,
ObjectId revId, String abbrRevId, String raw) throws GitAPIException,
IOException {
Map<String, String> macros = Maps.newHashMap();
String url = webUrl.get();
if (Strings.isNullOrEmpty(url)) {
url = "http://" + DEFAULT_HOST + "/";
}
macros.put("URL", url);
macros.put("PROJECT", project.get());
macros.put("PROJECT_URL", url + "#/admin/projects/" + project.get());
macros.put("REVISION", abbrRevId);
try (Git git = new Git(repo)) {
macros.put("GIT_DESCRIPTION", MoreObjects.firstNonNull(
git.describe().setTarget(revId).call(), abbrRevId));
}
Matcher m = Pattern.compile("(\\\\)?@([A-Z_]+)@").matcher(raw);
StringBuffer sb = new StringBuffer();
while (m.find()) {
String key = m.group(2);
String val = macros.get(key);
if (m.group(1) != null || val == null) {
m.appendReplacement(sb, "@" + key + "@");
} else {
m.appendReplacement(sb, val);
}
}
m.appendTail(sb);
return sb.toString();
}
private Resource getAsHtmlResource(String html, int lastModified) {
return new SmallResource(html.getBytes(UTF_8))
.setContentType("text/html")
.setCharacterEncoding(UTF_8.name())
.setLastModified(lastModified);
}
public static class Module extends CacheModule {
static final String X_DOC_RESOURCES = "x_doc_resources";
@Override
protected void configure() {
install(new CacheModule() {
@Override
protected void configure() {
persist(X_DOC_RESOURCES, String.class, Resource.class)
.maximumWeight(2 << 20)
.weigher(XDocResourceWeigher.class)
.loader(XDocLoader.class);
}
});
}
}
private static class XDocResourceWeigher implements
Weigher<String, Resource> {
@Override
public int weigh(String key, Resource value) {
return key.length() * 2 + value.weigh();
}
}
}