001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.views.markdown;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022
023import org.commonmark.Extension;
024import org.commonmark.ext.gfm.tables.TablesExtension;
025import org.commonmark.node.Node;
026import org.commonmark.parser.Parser;
027import org.commonmark.renderer.html.AttributeProvider;
028import org.commonmark.renderer.html.HtmlRenderer;
029
030/**
031 * The default {@link MarkdownRenderer} implementation: a thin wrapper around
032 * <a class="doclink" href="https://github.com/commonmark/commonmark-java">commonmark-java</a> with the GFM tables
033 * extension enabled, HTML-escaped raw markup, and URL allowlisting on {@code <a href>} / {@code <img src>}.
034 *
035 * <p>
036 * The GFM tables extension is on by default because real-world documents (runbooks, onboarding guides, skills)
037 * routinely use pipe tables, and core CommonMark renders them as paragraphs of literal pipes.
038 *
039 * <p>
040 * Raw HTML in the markdown source is escaped ({@code escapeHtml(true)}), so a {@code <script>} or
041 * {@code <img onerror>} in a {@code SKILL.md} becomes visible text, not an element.  Link and image URLs are
042 * restricted to {@code http}/{@code https}/{@code mailto}, fragments, and scheme-less relative paths;
043 * {@code javascript:}, {@code data:}, and {@code vbscript:} are stripped.
044 *
045 * <p>
046 * commonmark-java's {@link Parser} and {@link HtmlRenderer} are immutable and thread-safe once built, so a single
047 * instance of this class is safe to share across threads and reuse for every render.
048 *
049 * <h5 class='section'>See Also:</h5>
050 * <ul>
051 *    <li class='jc'>{@link MarkdownRenderer}
052 *    <li class='link'><a class="doclink" href="https://github.github.com/gfm/#tables-extension-">GFM tables extension</a>
053 * </ul>
054 *
055 * @since 10.0.0
056 */
057public class CommonmarkMarkdownRenderer implements MarkdownRenderer {
058
059   private final Parser parser;
060   private final HtmlRenderer renderer;
061
062   /**
063    * Constructor.
064    *
065    * <p>
066    * Builds a parser/renderer pair with the GFM tables extension enabled, raw HTML escaped, and unsafe URLs
067    * stripped.
068    */
069   public CommonmarkMarkdownRenderer() {
070      List<Extension> extensions = List.of(TablesExtension.create());
071      parser = Parser.builder().extensions(extensions).build();
072      renderer = HtmlRenderer.builder()
073         .extensions(extensions)
074         .escapeHtml(true)
075         .attributeProviderFactory(ctx -> new SafeUrlAttributeProvider())
076         .build();
077   }
078
079   @Override /* MarkdownRenderer */
080   public String toHtml(String markdown) {
081      if (markdown == null)
082         throw iaex("Markdown source must not be null.");
083      return renderer.render(parser.parse(markdown));
084   }
085
086   /**
087    * Whether {@code url} is safe to emit as an {@code href} or {@code src}: {@code http}/{@code https}/
088    * {@code mailto}, a fragment, a same-origin path, or a scheme-less relative URL.
089    *
090    * @param url The candidate URL.  May be <jk>null</jk>.
091    * @return <jk>true</jk> if the URL may be copied onto an element.
092    */
093   public static boolean isSafeUrl(String url) {
094      if (ib(url))
095         return false;
096      var t = url.trim();
097      var lower = t.toLowerCase(Locale.ROOT);
098      if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:"))
099         return false;
100      if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("mailto:"))
101         return true;
102      if (t.charAt(0) == '#' || t.charAt(0) == '/')
103         return true;
104      return lower.indexOf(':') < 0;
105   }
106
107   private static final class SafeUrlAttributeProvider implements AttributeProvider {
108      @Override
109      public void setAttributes(Node node, String tagName, Map<String,String> attributes) {
110         if (eq(tagName, "a") && attributes.containsKey("href") && ! isSafeUrl(attributes.get("href")))
111            attributes.remove("href");
112         if (eq(tagName, "img") && attributes.containsKey("src") && ! isSafeUrl(attributes.get("src")))
113            attributes.remove("src");
114      }
115   }
116}