Refactor MarkdownToHtml with Builder pattern This refactoring simplifies how we access information about the document being rendered, which makes the navbar.md, README.md and normal markdown files all more consistent. MarkdownConfig is now a JGit SectionParser managed object, which allows caching the immutable MarkdownConfig inside the JGit Config. This consolidates the key references from all over the code to one location, and may improve performance for servers that reuse the Config object across requests. Change-Id: Id85130d1bc7351f125bf3b0f8bf3c6e028272b30
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java b/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java index 9a05f35..44024b0 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/ReadmeHelper.java
@@ -13,24 +13,20 @@ // limitations under the License. package com.google.gitiles; - import com.google.gitiles.doc.GitilesMarkdown; -import com.google.gitiles.doc.ImageLoader; +import com.google.gitiles.doc.MarkdownConfig; import com.google.gitiles.doc.MarkdownToHtml; import com.google.template.soy.data.SanitizedContent; -import org.commonmark.node.Node; import org.eclipse.jgit.errors.CorruptObjectException; import org.eclipse.jgit.errors.IncorrectObjectTypeException; import org.eclipse.jgit.errors.MissingObjectException; -import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.FileMode; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectReader; import org.eclipse.jgit.revwalk.RevTree; import org.eclipse.jgit.treewalk.TreeWalk; -import org.eclipse.jgit.util.RawParseUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,25 +37,23 @@ private final ObjectReader reader; private final GitilesView view; - private final Config cfg; + private final MarkdownConfig config; private final RevTree rootTree; - private final boolean render; private String readmePath; private ObjectId readmeId; - ReadmeHelper(ObjectReader reader, GitilesView view, Config cfg, RevTree rootTree) { + ReadmeHelper(ObjectReader reader, GitilesView view, MarkdownConfig config, RevTree rootTree) { this.reader = reader; this.view = view; - this.cfg = cfg; + this.config = config; this.rootTree = rootTree; - render = cfg.getBoolean("markdown", "render", true); } void scanTree(RevTree tree) throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException, IOException { - if (render) { + if (config.render) { TreeWalk tw = new TreeWalk(reader); tw.setRecursive(false); tw.addTree(tree); @@ -70,7 +64,7 @@ } void considerEntry(TreeWalk tw) { - if (render + if (config.render && FileMode.REGULAR_FILE.equals(tw.getRawMode(0)) && isReadmeFile(tw.getNameString())) { readmePath = tw.getPathString(); @@ -88,21 +82,15 @@ SanitizedContent render() { try { - int inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20); - byte[] raw = reader.open(readmeId, Constants.OBJ_BLOB).getCachedBytes(inputLimit); - String md = RawParseUtils.decode(raw); - Node root = GitilesMarkdown.parse(md); - if (root == null) { - return null; - } - - int imageLimit = cfg.getInt("markdown", "imageLimit", 256 << 10); - ImageLoader img = null; - if (imageLimit > 0) { - img = new ImageLoader(reader, view, rootTree, readmePath, imageLimit); - } - - return new MarkdownToHtml(view, cfg, readmePath).setImageLoader(img).toSoyHtml(root); + byte[] raw = reader.open(readmeId, Constants.OBJ_BLOB).getCachedBytes(config.inputLimit); + return MarkdownToHtml.builder() + .setConfig(config) + .setGitilesView(view) + .setFilePath(readmePath) + .setReader(reader) + .setRootTree(rootTree) + .build() + .toSoyHtml(GitilesMarkdown.parse(raw)); } catch (RuntimeException | IOException err) { log.error( String.format(
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java index f4d9327..a6758a9 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/RepositoryIndexServlet.java
@@ -23,6 +23,7 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; import com.google.gitiles.DateFormatter.Format; +import com.google.gitiles.doc.MarkdownConfig; import com.google.gson.reflect.TypeToken; import com.google.template.soy.data.SanitizedContent; @@ -168,7 +169,7 @@ new ReadmeHelper( walk.getObjectReader(), GitilesView.path().copyFrom(view).setRevision(Revision.HEAD).setPathPart("/").build(), - cfg, + MarkdownConfig.get(cfg), rootTree); readme.scanTree(rootTree); if (readme.isPresent()) {
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java index f1366ad..056ae24 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/TreeSoyData.java
@@ -22,6 +22,7 @@ import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.gitiles.PathServlet.FileType; +import com.google.gitiles.doc.MarkdownConfig; import org.eclipse.jgit.errors.MissingObjectException; import org.eclipse.jgit.lib.Config; @@ -88,7 +89,7 @@ public Map<String, Object> toSoyData(ObjectId treeId, TreeWalk tw) throws MissingObjectException, IOException { - ReadmeHelper readme = new ReadmeHelper(reader, view, cfg, rootTree); + ReadmeHelper readme = new ReadmeHelper(reader, view, MarkdownConfig.get(cfg), rootTree); List<Object> entries = Lists.newArrayList(); GitilesView.Builder urlBuilder = GitilesView.path().copyFrom(view); while (tw.next()) {
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java index 8c30e26..e669014 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/DocServlet.java
@@ -37,26 +37,28 @@ import org.commonmark.node.Node; import org.eclipse.jgit.errors.IncorrectObjectTypeException; +import org.eclipse.jgit.errors.LargeObjectException; import org.eclipse.jgit.http.server.ServletUtils; -import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.Constants; 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.RevTree; import org.eclipse.jgit.revwalk.RevWalk; 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.HashMap; import java.util.Map; +import javax.annotation.Nullable; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class DocServlet extends BaseServlet { + private static final Logger log = LoggerFactory.getLogger(DocServlet.class); private static final long serialVersionUID = 1L; private static final String INDEX_MD = "index.md"; @@ -75,8 +77,8 @@ @Override protected void doGetHtml(HttpServletRequest req, HttpServletResponse res) throws IOException { - Config cfg = getAccess(req).getConfig(); - if (!cfg.getBoolean("markdown", "render", true)) { + MarkdownConfig cfg = MarkdownConfig.get(getAccess(req).getConfig()); + if (!cfg.render) { res.setStatus(SC_NOT_FOUND); return; } @@ -84,6 +86,7 @@ GitilesView view = ViewFilter.getView(req); Repository repo = ServletUtils.getRepository(req); try (RevWalk rw = new RevWalk(repo)) { + ObjectReader reader = rw.getObjectReader(); String path = view.getPathPart(); RevTree root; try { @@ -93,53 +96,50 @@ return; } - SourceFile srcmd = findFile(rw, root, path); + MarkdownFile srcmd = findFile(rw, root, path); if (srcmd == null) { res.setStatus(SC_NOT_FOUND); return; } - SourceFile navmd = findFile(rw, root, NAVBAR_MD); - String reqEtag = req.getHeader(HttpHeaders.IF_NONE_MATCH); + MarkdownFile navmd = findFile(rw, root, NAVBAR_MD); String curEtag = etag(srcmd, navmd); - if (reqEtag != null && reqEtag.equals(curEtag)) { + if (etagMatch(req, curEtag)) { res.setStatus(SC_NOT_MODIFIED); return; } view = view.toBuilder().setPathPart(srcmd.path).build(); - int inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20); - Node doc = GitilesMarkdown.parse(srcmd.read(rw.getObjectReader(), inputLimit)); - if (doc == null) { - res.sendRedirect(GitilesView.show().copyFrom(view).toUrl()); + try { + srcmd.read(reader, cfg); + if (navmd != null) { + navmd.read(reader, cfg); + } + } catch (LargeObjectException.ExceedsLimit errBig) { + fileTooBig(res, view, errBig); + return; + } catch (IOException err) { + readError(res, view, err); return; } - String navPath = null; - String navMarkdown = null; - Node nav = null; - if (navmd != null) { - navPath = navmd.path; - navMarkdown = navmd.read(rw.getObjectReader(), inputLimit); - nav = GitilesMarkdown.parse(navMarkdown); - if (nav == null) { - res.setStatus(SC_INTERNAL_SERVER_ERROR); - return; - } - } - - int imageLimit = cfg.getInt("markdown", "imageLimit", 256 << 10); - ImageLoader img = null; - if (imageLimit > 0) { - img = new ImageLoader(rw.getObjectReader(), view, root, srcmd.path, imageLimit); - } - + MarkdownToHtml.Builder fmt = + MarkdownToHtml.builder() + .setConfig(cfg) + .setGitilesView(view) + .setReader(reader) + .setRootTree(root); res.setHeader(HttpHeaders.ETAG, curEtag); - showDoc(req, res, view, cfg, img, navPath, navMarkdown, nav, srcmd.path, doc); + showDoc(req, res, view, cfg, fmt, navmd, srcmd); } } - private String etag(SourceFile srcmd, SourceFile navmd) { + private static boolean etagMatch(HttpServletRequest req, String etag) { + String reqEtag = req.getHeader(HttpHeaders.IF_NONE_MATCH); + return reqEtag != null && reqEtag.equals(etag); + } + + private String etag(MarkdownFile srcmd, @Nullable MarkdownFile navmd) { byte[] b = new byte[Constants.OBJECT_ID_LENGTH]; Hasher h = Hashing.sha1().newHasher(); h.putInt(ETAG_GEN); @@ -169,32 +169,30 @@ HttpServletRequest req, HttpServletResponse res, GitilesView view, - Config cfg, - ImageLoader img, - String navPath, - String navMarkdown, - Node nav, - String docPath, - Node doc) + MarkdownConfig cfg, + MarkdownToHtml.Builder fmt, + MarkdownFile navFile, + MarkdownFile srcFile) throws IOException { Map<String, Object> data = new HashMap<>(); + Navbar navbar = new Navbar(); + if (navFile != null) { + navbar.setFormatter(fmt.setFilePath(navFile.path).build()); + navbar.setMarkdown(navFile.content); + } + data.putAll(navbar.toSoyData()); - MarkdownToHtml navHtml = new MarkdownToHtml(view, cfg, navPath); - data.putAll(Navbar.bannerSoyData(img, navHtml, navMarkdown, nav)); - data.put("navbarHtml", navHtml.toSoyHtml(nav)); - - data.put("pageTitle", MoreObjects.firstNonNull(MarkdownUtil.getTitle(doc), view.getPathPart())); + Node doc = GitilesMarkdown.parse(srcFile.content); + data.put("pageTitle", pageTitle(doc, srcFile)); if (view.getType() != GitilesView.Type.ROOTED_DOC) { data.put("sourceUrl", GitilesView.show().copyFrom(view).toUrl()); data.put("logUrl", GitilesView.log().copyFrom(view).toUrl()); data.put("blameUrl", GitilesView.blame().copyFrom(view).toUrl()); } - data.put("bodyHtml", new MarkdownToHtml(view, cfg, docPath).setImageLoader(img).toSoyHtml(doc)); - - String analyticsId = cfg.getString("google", null, "analyticsId"); - if (!Strings.isNullOrEmpty(analyticsId)) { - data.put("analyticsId", analyticsId); + if (cfg.analyticsId != null) { + data.put("analyticsId", cfg.analyticsId); } + data.put("bodyHtml", fmt.setFilePath(srcFile.path).build().toSoyHtml(doc)); String page = renderer.render(SOY_TEMPLATE, data); byte[] raw = page.getBytes(UTF_8); @@ -210,7 +208,13 @@ res.getOutputStream().write(raw); } - private static SourceFile findFile(RevWalk rw, RevTree root, String path) throws IOException { + private static String pageTitle(Node doc, MarkdownFile srcFile) { + String title = MarkdownUtil.getTitle(doc); + return MoreObjects.firstNonNull(title, srcFile.path); + } + + @Nullable + private static MarkdownFile findFile(RevWalk rw, RevTree root, String path) throws IOException { if (Strings.isNullOrEmpty(path)) { path = INDEX_MD; } @@ -231,7 +235,7 @@ if (!path.endsWith(".md")) { return null; } - return new SourceFile(path, tw.getObjectId(0)); + return new MarkdownFile(path, tw.getObjectId(0)); } return null; } @@ -246,19 +250,48 @@ return false; } - private static class SourceFile { + private static void fileTooBig( + HttpServletResponse res, GitilesView view, LargeObjectException.ExceedsLimit errBig) + throws IOException { + if (view.getType() == GitilesView.Type.ROOTED_DOC) { + log.error( + String.format( + "markdown too large: %s/%s %s %s: %s", + view.getHostName(), + view.getRepositoryName(), + view.getRevision(), + view.getPathPart(), + errBig.getMessage())); + res.setStatus(SC_INTERNAL_SERVER_ERROR); + } else { + res.sendRedirect(GitilesView.show().copyFrom(view).toUrl()); + } + } + + private static void readError(HttpServletResponse res, GitilesView view, IOException err) { + log.error( + String.format( + "cannot load markdown %s/%s %s %s", + view.getHostName(), + view.getRepositoryName(), + view.getRevision(), + view.getPathPart()), + err); + res.setStatus(SC_INTERNAL_SERVER_ERROR); + } + + private static class MarkdownFile { final String path; final ObjectId id; + byte[] content; - SourceFile(String path, ObjectId id) { + MarkdownFile(String path, ObjectId id) { this.path = path; this.id = id; } - String read(ObjectReader reader, int inputLimit) throws IOException { - ObjectLoader obj = reader.open(id, OBJ_BLOB); - byte[] raw = obj.getCachedBytes(inputLimit); - return RawParseUtils.decode(raw); + void read(ObjectReader reader, MarkdownConfig cfg) throws IOException { + content = reader.open(id, OBJ_BLOB).getCachedBytes(cfg.inputLimit); } } }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java index 541343b..fcd00dc 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/GitilesMarkdown.java
@@ -21,6 +21,7 @@ import org.commonmark.ext.gfm.tables.TablesExtension; import org.commonmark.node.Node; import org.commonmark.parser.Parser; +import org.eclipse.jgit.util.RawParseUtils; /** Parses Gitiles style CommonMark Markdown. */ public class GitilesMarkdown { @@ -40,8 +41,12 @@ TocExtension.create())) .build(); + public static Node parse(byte[] md) { + return parse(RawParseUtils.decode(md)); + } + public static Node parse(String md) { - return md != null ? PARSER.parse(md) : null; + return PARSER.parse(md); } private GitilesMarkdown() {}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/ImageLoader.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/ImageLoader.java index b381616..39238e1 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/ImageLoader.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/ImageLoader.java
@@ -31,77 +31,66 @@ import java.io.IOException; +import javax.annotation.Nullable; + /** Reads an image from Git and converts to {@code data:image/*;base64,...} */ -public class ImageLoader { +class ImageLoader { private static final Logger log = LoggerFactory.getLogger(ImageLoader.class); private final ObjectReader reader; private final GitilesView view; + private final MarkdownConfig config; private final RevTree root; - private final String path; - private final int imageLimit; - public ImageLoader( - ObjectReader reader, GitilesView view, RevTree root, String path, int maxImageSize) { + ImageLoader(ObjectReader reader, GitilesView view, MarkdownConfig config, RevTree root) { this.reader = reader; this.view = view; + this.config = config; this.root = root; - this.path = path; - this.imageLimit = maxImageSize; } - String loadImage(String src) { - if (src.startsWith("/")) { - return readAndBase64Encode(src.substring(1)); + String inline(@Nullable String markdownPath, String imagePath) { + String data = inlineMaybe(markdownPath, imagePath); + if (data != null) { + return data; } - - String base = directory(); - while (src.startsWith("../")) { - int s = base.lastIndexOf('/'); - if (s == -1) { - return FilterImageDataUri.INSTANCE.getInnocuousOutput(); - } - base = base.substring(0, s + 1); - src = src.substring("../".length()); - } - return readAndBase64Encode(base + src); + return FilterImageDataUri.INSTANCE.getInnocuousOutput(); } - private String directory() { - int s = path.lastIndexOf('/'); - if (s > 0) { - return path.substring(0, s + 1); + private String inlineMaybe(@Nullable String markdownPath, String imagePath) { + if (config.imageLimit <= 0) { + return null; } - return ""; - } - private String readAndBase64Encode(String path) { + String path = PathResolver.resolve(markdownPath, imagePath); + if (path == null) { + return null; + } + String type = getMimeType(path); if (type == null) { - return FilterImageDataUri.INSTANCE.getInnocuousOutput(); + return null; } try { TreeWalk tw = TreeWalk.forPath(reader, path, root); if (tw == null || tw.getFileMode(0) != FileMode.REGULAR_FILE) { - return FilterImageDataUri.INSTANCE.getInnocuousOutput(); + return null; } ObjectId id = tw.getObjectId(0); - byte[] raw = reader.open(id, Constants.OBJ_BLOB).getCachedBytes(imageLimit); - if (raw.length > imageLimit) { - return FilterImageDataUri.INSTANCE.getInnocuousOutput(); + byte[] raw = reader.open(id, Constants.OBJ_BLOB).getCachedBytes(config.imageLimit); + if (raw.length > config.imageLimit) { + return null; } - return "data:" + type + ";base64," + BaseEncoding.base64().encode(raw); } catch (LargeObjectException.ExceedsLimit e) { - return FilterImageDataUri.INSTANCE.getInnocuousOutput(); - } catch (IOException e) { + return null; + } catch (IOException err) { + String repo = view != null ? view.getRepositoryName() : "<unknown>"; log.error( - String.format( - "cannot read repo %s image %s from %s", view.getRepositoryName(), path, root.name()), - e); - return FilterImageDataUri.INSTANCE.getInnocuousOutput(); + String.format("cannot read repo %s image %s from %s", repo, path, root.name()), err); + return null; } }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownConfig.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownConfig.java new file mode 100644 index 0000000..f229f8e --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownConfig.java
@@ -0,0 +1,74 @@ +// Copyright (C) 2016 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.google.gitiles.doc; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; + +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.lib.Config.SectionParser; +import org.eclipse.jgit.util.StringUtils; + +public class MarkdownConfig { + public static final int IMAGE_LIMIT = 256 << 10; + + public static MarkdownConfig get(Config cfg) { + return cfg.get(CONFIG_PARSER); + } + + private static SectionParser<MarkdownConfig> CONFIG_PARSER = + new SectionParser<MarkdownConfig>() { + @Override + public MarkdownConfig parse(Config cfg) { + return new MarkdownConfig(cfg); + } + }; + + public final boolean render; + public final int inputLimit; + + final int imageLimit; + final String analyticsId; + + private final boolean allowAnyIFrame; + private final ImmutableList<String> allowIFrame; + + MarkdownConfig(Config cfg) { + render = cfg.getBoolean("markdown", "render", true); + inputLimit = cfg.getInt("markdown", "inputLimit", 5 << 20); + imageLimit = cfg.getInt("markdown", "imageLimit", IMAGE_LIMIT); + analyticsId = Strings.emptyToNull(cfg.getString("google", null, "analyticsId")); + + String[] f = cfg.getStringList("markdown", null, "allowiframe"); + allowAnyIFrame = f.length == 1 && StringUtils.toBooleanOrNull(f[0]) == Boolean.TRUE; + if (allowAnyIFrame) { + allowIFrame = ImmutableList.of(); + } else { + allowIFrame = ImmutableList.copyOf(f); + } + } + + boolean isIFrameAllowed(String src) { + if (allowAnyIFrame) { + return true; + } + for (String url : allowIFrame) { + if (url.equals(src) || (url.endsWith("/") && src.startsWith(url))) { + return true; + } + } + return false; + } +}
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java index 1b88d5b..ae78862 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/MarkdownToHtml.java
@@ -22,6 +22,7 @@ import com.google.gitiles.ThreadSafePrettifyParser; import com.google.gitiles.doc.html.HtmlBuilder; import com.google.template.soy.data.SanitizedContent; +import com.google.template.soy.shared.restricted.EscapingConventions.FilterImageDataUri; import com.google.template.soy.shared.restricted.EscapingConventions.FilterNormalizeUri; import org.commonmark.ext.gfm.strikethrough.Strikethrough; @@ -56,13 +57,15 @@ import org.commonmark.node.Text; import org.commonmark.node.ThematicBreak; import org.commonmark.node.Visitor; -import org.eclipse.jgit.lib.Config; -import org.eclipse.jgit.util.StringUtils; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.revwalk.RevTree; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.annotation.Nullable; + import prettify.parser.Prettify; import syntaxhighlight.ParseResult; @@ -72,34 +75,69 @@ * Callers must create a new instance for each document. */ public class MarkdownToHtml implements Visitor { + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private GitilesView view; + private MarkdownConfig config; + private String filePath; + private ObjectReader reader; + private RevTree root; + + Builder() {} + + public Builder setGitilesView(@Nullable GitilesView view) { + this.view = view; + return this; + } + + public Builder setConfig(@Nullable MarkdownConfig config) { + this.config = config; + return this; + } + + public Builder setFilePath(@Nullable String filePath) { + this.filePath = Strings.emptyToNull(filePath); + return this; + } + + public Builder setReader(ObjectReader reader) { + this.reader = reader; + return this; + } + + public Builder setRootTree(RevTree tree) { + this.root = tree; + return this; + } + + public MarkdownToHtml build() { + return new MarkdownToHtml(this); + } + } + private final HtmlBuilder html = new HtmlBuilder(); private final TocFormatter toc = new TocFormatter(html, 3); private final GitilesView view; - private final Config cfg; + private final MarkdownConfig config; private final String filePath; - private ImageLoader imageLoader; + private final ImageLoader imageLoader; private boolean outputNamedAnchor = true; - /** - * Initialize a Markdown to HTML converter. - * - * @param view view used to access this Markdown on the web. Some elements of - * the view may be used to generate hyperlinks to other files, e.g. - * repository name and revision. - * @param cfg - * @param filePath actual path of the Markdown file in the Git repository. This must - * always be a file, e.g. {@code doc/README.md}. The path is used to - * resolve relative links within the repository. - */ - public MarkdownToHtml(GitilesView view, Config cfg, String filePath) { - this.view = view; - this.cfg = cfg; - this.filePath = filePath; + private MarkdownToHtml(Builder b) { + view = b.view; + config = b.config; + filePath = b.filePath; + imageLoader = newImageLoader(b); } - public MarkdownToHtml setImageLoader(ImageLoader img) { - imageLoader = img; - return this; + private static ImageLoader newImageLoader(Builder b) { + if (b.reader != null && b.view != null && b.config != null && b.root != null) { + return new ImageLoader(b.reader, b.view, b.config, b.root); + } + return null; } /** Render the document AST to sanitized HTML. */ @@ -148,7 +186,8 @@ if (HtmlBuilder.isValidHttpUri(node.src) && HtmlBuilder.isValidCssDimension(node.height) && HtmlBuilder.isValidCssDimension(node.width) - && canRender(node)) { + && config != null + && config.isIFrameAllowed(node.src)) { html.open("iframe") .attribute("src", node.src) .attribute("height", node.height) @@ -160,19 +199,6 @@ } } - private boolean canRender(IframeBlock node) { - String[] ok = cfg.getStringList("markdown", null, "allowiframe"); - if (ok.length == 1 && StringUtils.toBooleanOrNull(ok[0]) == Boolean.TRUE) { - return true; - } - for (String m : ok) { - if (m.equals(node.src) || (m.endsWith("/") && node.src.startsWith(m))) { - return true; - } - } - return false; // By default do not render iframe. - } - @Override public void visit(Heading node) { outputNamedAnchor = false; @@ -341,61 +367,35 @@ target = target.substring(0, hash); } - if (target.startsWith("/")) { - return toPath(target) + anchor; + String dest = PathResolver.resolve(filePath, target); + if (dest == null || view == null) { + return FilterNormalizeUri.INSTANCE.getInnocuousOutput(); } - String dir = trimLastComponent(filePath); - while (!target.isEmpty()) { - if (target.startsWith("../") || target.equals("..")) { - if (dir.isEmpty()) { - return FilterNormalizeUri.INSTANCE.getInnocuousOutput(); - } - dir = trimLastComponent(dir); - target = target.equals("..") ? "" : target.substring(3); - } else if (target.startsWith("./")) { - target = target.substring(2); - } else if (target.equals(".")) { - target = ""; - } else { - break; - } - } - - return toPath(dir + '/' + target) + anchor; - } - - private static String trimLastComponent(String path) { - int slash = path.lastIndexOf('/'); - return slash < 0 ? "" : path.substring(0, slash); - } - - private String toPath(String path) { GitilesView.Builder b; if (view.getType() == GitilesView.Type.ROOTED_DOC) { b = GitilesView.rootedDoc(); } else { b = GitilesView.path(); } - return b.copyFrom(view).setPathPart(path).build().toUrl(); + return b.copyFrom(view).setPathPart(dest).build().toUrl() + anchor; } @Override public void visit(Image node) { html.open("img") - .attribute("src", resolveImageUrl(node.getDestination())) + .attribute("src", image(node.getDestination())) .attribute("title", node.getTitle()) .attribute("alt", getInnerText(node)); } - private String resolveImageUrl(String url) { - if (imageLoader == null - || url.startsWith("https://") - || url.startsWith("http://") - || url.startsWith("data:")) { - return url; + String image(String dest) { + if (HtmlBuilder.isValidHttpUri(dest) || HtmlBuilder.isImageDataUri(dest)) { + return dest; + } else if (imageLoader != null) { + return imageLoader.inline(filePath, dest); } - return imageLoader.loadImage(url); + return FilterImageDataUri.INSTANCE.getInnocuousOutput(); } public void visit(TableBlock node) {
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Navbar.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/Navbar.java index c1ed246..5affd6c 100644 --- a/gitiles-servlet/src/main/java/com/google/gitiles/doc/Navbar.java +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/Navbar.java
@@ -15,11 +15,12 @@ package com.google.gitiles.doc; import com.google.gitiles.doc.html.HtmlBuilder; -import com.google.template.soy.shared.restricted.EscapingConventions.FilterImageDataUri; import com.google.template.soy.shared.restricted.Sanitizers; +import com.google.template.soy.shared.restricted.EscapingConventions.FilterImageDataUri; import org.commonmark.node.Heading; import org.commonmark.node.Node; +import org.eclipse.jgit.util.RawParseUtils; import java.util.HashMap; import java.util.Map; @@ -30,61 +31,83 @@ private static final Pattern REF_LINK = Pattern.compile("^\\[(logo|home)\\]:\\s*(.+)$", Pattern.MULTILINE | Pattern.CASE_INSENSITIVE); - static Map<String, Object> bannerSoyData( - ImageLoader img, MarkdownToHtml toHtml, String navMarkdown, Node nav) { - Map<String, Object> data = new HashMap<>(); - data.put("siteTitle", null); - data.put("logoUrl", null); - data.put("homeUrl", null); + private MarkdownToHtml fmt; + private Node node; + private String siteTitle; + private String logoUrl; + private String homeUrl; - if (nav == null) { - return data; + Navbar() {} + + Navbar setFormatter(MarkdownToHtml html) { + this.fmt = html; + return this; + } + + Navbar setMarkdown(byte[] md) { + if (md != null && md.length > 0) { + parse(RawParseUtils.decode(md)); + } + return this; + } + + Map<String, Object> toSoyData() { + Map<String, Object> data = new HashMap<>(); + data.put("siteTitle", siteTitle); + data.put("logoUrl", logo()); + data.put("homeUrl", homeUrl != null ? fmt.href(homeUrl) : null); + data.put("navbarHtml", node != null ? fmt.toSoyHtml(node) : null); + return data; + } + + private Object logo() { + if (logoUrl == null) { + return null; } - for (Node c = nav.getFirstChild(); c != null; c = c.getNext()) { + String url = fmt.image(logoUrl); + if (HtmlBuilder.isValidHttpUri(url)) { + return url; + } else if (HtmlBuilder.isImageDataUri(url)) { + return Sanitizers.filterImageDataUri(url); + } else { + return FilterImageDataUri.INSTANCE.getInnocuousOutput(); + } + } + + private void parse(String markdown) { + node = GitilesMarkdown.parse(markdown); + + extractSiteTitle(); + extractRefLinks(markdown); + } + + private void extractSiteTitle() { + for (Node c = node.getFirstChild(); c != null; c = c.getNext()) { if (c instanceof Heading) { Heading h = (Heading) c; if (h.getLevel() == 1) { - data.put("siteTitle", MarkdownUtil.getInnerText(h)); + siteTitle = MarkdownUtil.getInnerText(h); h.unlink(); break; } } } + } - Matcher m = REF_LINK.matcher(navMarkdown); + private void extractRefLinks(String markdown) { + Matcher m = REF_LINK.matcher(markdown); while (m.find()) { String key = m.group(1).toLowerCase(); String url = m.group(2).trim(); switch (key) { case "logo": - data.put("logoUrl", toImgSrc(img, url)); + logoUrl = url; break; - case "home": - data.put("homeUrl", toHtml.href(url)); + homeUrl = url; break; } } - - return data; } - - private static Object toImgSrc(ImageLoader img, String url) { - if (HtmlBuilder.isValidHttpUri(url)) { - return url; - } - - if (HtmlBuilder.isImageDataUri(url)) { - return Sanitizers.filterImageDataUri(url); - } - - if (img != null) { - return Sanitizers.filterImageDataUri(img.loadImage(url)); - } - - return FilterImageDataUri.INSTANCE.getInnocuousOutput(); - } - - private Navbar() {} }
diff --git a/gitiles-servlet/src/main/java/com/google/gitiles/doc/PathResolver.java b/gitiles-servlet/src/main/java/com/google/gitiles/doc/PathResolver.java new file mode 100644 index 0000000..045ecda --- /dev/null +++ b/gitiles-servlet/src/main/java/com/google/gitiles/doc/PathResolver.java
@@ -0,0 +1,71 @@ +// Copyright (C) 2016 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.google.gitiles.doc; + +import com.google.common.base.CharMatcher; + +import javax.annotation.Nullable; + +class PathResolver { + /** + * Resolve a path within the repository. + * + * @param file path of the Markdown file in the repository that is making the + * reference. May be null. + * @param target destination within the repository. If {@code target} starts + * with {@code '/'}, {@code file} may be null and {@code target} is + * evaluated as from the root directory of the repository. + * @return resolved form of {@code target} within the repository. Null if + * {@code target} is not valid from {@code file}. Does not begin with + * {@code '/'}, even if {@code target} does. + */ + @Nullable + static String resolve(@Nullable String file, String target) { + if (target.startsWith("/")) { + return trimLeadingSlash(target); + } else if (file == null) { + return null; + } + + String dir = trimLastComponent(trimLeadingSlash(file)); + while (!target.isEmpty()) { + if (target.startsWith("../") || target.equals("..")) { + if (dir.isEmpty()) { + return null; + } + dir = trimLastComponent(dir); + target = target.equals("..") ? "" : target.substring(3); + } else if (target.startsWith("./")) { + target = target.substring(2); + } else if (target.equals(".")) { + target = ""; + } else { + break; + } + } + return trimLeadingSlash(dir + '/' + target); + } + + private static String trimLeadingSlash(String s) { + return CharMatcher.is('/').trimLeadingFrom(s); + } + + private static String trimLastComponent(String path) { + int slash = path.lastIndexOf('/'); + return slash < 0 ? "" : path.substring(0, slash); + } + + private PathResolver() {} +}