| // 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 { |
| FormatterProvider formatter = getFormatter(key.getFormatter()); |
| Repository repo = repoManager.openRepository(key.getProject()); |
| try { |
| RevWalk rw = new RevWalk(repo); |
| try { |
| 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()); |
| } finally { |
| rw.release(); |
| } |
| } finally { |
| repo.close(); |
| } |
| } 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(); |
| TreeWalk tw = new TreeWalk(repo); |
| try { |
| 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); |
| } finally { |
| tw.release(); |
| } |
| } |
| |
| 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 ((StreamFormatter) 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 '>' 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(">", ">") |
| + 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 { |
| ObjectReader reader = repo.newObjectReader(); |
| try { |
| return reader.abbreviate(revId).name(); |
| } finally { |
| reader.release(); |
| } |
| } |
| |
| 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); |
| macros.put("GIT_DESCRIPTION", MoreObjects.firstNonNull( |
| (new Git(repo)).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(); |
| } |
| } |
| } |