main
rb 206 lines 6.84 KB
Raw
1 require "redcarpet"
2 require "rouge"
3 require "rails-html-sanitizer"
4 require "cgi"
5
6 # Renders GitHub-Flavored Markdown to sanitized HTML for README/.md display.
7 #
8 # Pipeline: Redcarpet (with a Rouge-highlighting, anchor-adding renderer) →
9 # task-list post-processing → allow-list sanitization. The sanitizer is the
10 # trust boundary: nothing the README author writes can introduce <script>,
11 # <iframe>, inline event handlers, or `javascript:` URLs, so rendered READMEs
12 # can never run as active content on the app origin.
13 #
14 # When a +context+ is supplied (owner/repo/ref/dir), relative links and images
15 # are resolved against the repository at the current ref — links point at the
16 # blob view, images at the raw endpoint.
17 class MarkdownRenderer
18 Context = Struct.new(:username, :repository, :ref, :dir, keyword_init: true)
19
20 # Tags we knowingly emit. Anything else is stripped to text.
21 ALLOWED_TAGS = %w[
22 h1 h2 h3 h4 h5 h6 p br hr blockquote
23 ul ol li dl dt dd
24 strong em b i del s strike code pre span tt kbd sub sup
25 a img
26 table thead tbody tfoot tr th td
27 input
28 details summary div
29 ].freeze
30
31 ALLOWED_ATTRIBUTES = %w[
32 href src alt title class id name
33 align colspan rowspan start
34 type checked disabled value
35 aria-hidden rel target loading
36 ].freeze
37
38 def self.render(text, context: nil)
39 new(context: context).render(text)
40 end
41
42 def initialize(context: nil)
43 @context = context
44 end
45
46 def render(text)
47 return "" if text.blank?
48
49 html = engine.render(text.to_s)
50 html = prune_dangerous_blocks(html)
51 html = convert_task_lists(html)
52 sanitize(html)
53 end
54
55 private
56
57 # Raw inline HTML in Markdown flows through Redcarpet untouched. The allow-list
58 # sanitizer below removes disallowed *tags* but keeps their text children, so a
59 # <script> would leave its code behind as visible (inert) text. Drop these
60 # blocks wholesale first so nothing leaks through — even as text.
61 PRUNE_BLOCKS = %r{<(script|style|noscript|template|svg|math|head|title|object|embed)\b[^>]*>.*?</\1>}mi
62
63 def prune_dangerous_blocks(html)
64 html.gsub(PRUNE_BLOCKS, "")
65 end
66
67 def engine
68 @engine ||= Redcarpet::Markdown.new(
69 Renderer.new(context: @context),
70 autolink: true,
71 tables: true,
72 fenced_code_blocks: true,
73 strikethrough: true,
74 superscript: true,
75 footnotes: true,
76 highlight: true,
77 space_after_headers: true,
78 no_intra_emphasis: true
79 )
80 end
81
82 # GitHub-style task lists. Redcarpet renders "- [ ] foo" as a plain list item
83 # ("[ ] foo"); rewrite the leading marker into a disabled checkbox. Handles
84 # both tight ("<li>[ ] x") and loose ("<li>\n<p>[ ] x") list rendering.
85 def convert_task_lists(html)
86 html.gsub(%r{(<li>\s*(?:<p>\s*)?)\[([ xX])\]\s}) do
87 prefix = Regexp.last_match(1)
88 checked = Regexp.last_match(2) != " "
89 box = %(<input type="checkbox" disabled#{checked ? ' checked' : ''}> )
90 %(#{prefix.sub('<li>', '<li class="task-list-item">')}#{box})
91 end
92 end
93
94 def sanitize(html)
95 sanitizer.sanitize(
96 html,
97 tags: ALLOWED_TAGS,
98 attributes: ALLOWED_ATTRIBUTES
99 )
100 end
101
102 def sanitizer
103 @sanitizer ||=
104 if defined?(Rails::HTML5::SafeListSanitizer)
105 Rails::HTML5::SafeListSanitizer.new
106 else
107 Rails::HTML4::SafeListSanitizer.new
108 end
109 end
110
111 # Redcarpet HTML renderer that adds server-side syntax highlighting to fenced
112 # code, anchor links to headings, and repository-relative URL resolution.
113 class Renderer < Redcarpet::Render::HTML
114 def initialize(context: nil)
115 @context = context
116 @heading_slugs = Hash.new(0)
117 # safe_links_only blocks dangerous schemes (javascript:, data: …) at the
118 # render layer too; sanitization is still the authoritative gate.
119 super(safe_links_only: true, link_attributes: { rel: "nofollow noopener noreferrer" })
120 end
121
122 def block_code(code, language)
123 lexer =
124 (Rouge::Lexer.find(language.to_s.split.first) if language.present?) ||
125 Rouge::Lexer.guess(source: code) rescue Rouge::Lexers::PlainText
126 lexer ||= Rouge::Lexers::PlainText
127
128 formatter = Rouge::Formatters::HTML.new
129 inner = formatter.format(lexer.lex(code))
130 lang_class = language.present? ? %( data-language="#{CGI.escapeHTML(language)}") : ""
131 %(<pre class="highlight code-block"#{lang_class}><code>#{inner}</code></pre>)
132 end
133
134 def header(text, level)
135 slug = slugify(text)
136 level = level.clamp(1, 6)
137 <<~HTML.strip
138 <h#{level} id="#{slug}" class="heading-anchored"><a class="heading-anchor" href="##{slug}" aria-hidden="true">#</a>#{text}</h#{level}>
139 HTML
140 end
141
142 def link(url, title, content)
143 resolved = resolve(url, raw: false)
144 title_attr = title.present? ? %( title="#{CGI.escapeHTML(title)}") : ""
145 %(<a href="#{CGI.escapeHTML(resolved)}"#{title_attr} rel="nofollow noopener noreferrer">#{content}</a>)
146 end
147
148 def image(url, title, alt)
149 resolved = resolve(url, raw: true)
150 alt_attr = %( alt="#{CGI.escapeHTML(alt.to_s)}")
151 title_attr = title.present? ? %( title="#{CGI.escapeHTML(title)}") : ""
152 %(<img src="#{CGI.escapeHTML(resolved)}"#{alt_attr}#{title_attr} loading="lazy">)
153 end
154
155 private
156
157 # Map a heading's inline HTML to a GitHub-compatible anchor slug, keeping
158 # successive duplicates unique (foo, foo-1, foo-2 …).
159 def slugify(text)
160 base = text.gsub(/<[^>]+>/, "") # drop inline tags
161 .downcase
162 .gsub(/[^\w\- ]/, "") # drop punctuation
163 .strip
164 .gsub(/\s+/, "-")
165 base = "section" if base.empty?
166 n = @heading_slugs[base]
167 @heading_slugs[base] += 1
168 n.zero? ? base : "#{base}-#{n}"
169 end
170
171 # Resolve a possibly-relative link/image against the repo at the current
172 # ref. Absolute URLs, anchors, and root-relative paths are left untouched.
173 def resolve(url, raw:)
174 url = url.to_s
175 return url if @context.nil? || url.empty?
176 return url if url.start_with?("#", "/", "mailto:")
177 return url if url =~ %r{\A[a-z][a-z0-9+.\-]*://}i # has a scheme
178
179 path = clean_path(url)
180 return url if path.nil?
181
182 kind = raw ? "raw" : "blob"
183 "/#{@context.username}/#{@context.repository}/#{kind}/#{@context.ref}/#{path}"
184 end
185
186 # Join the link against the current file's directory and collapse "."/".."
187 # without escaping the repository root.
188 def clean_path(url)
189 anchor = url[/#.*\z/]
190 url = url.sub(/#.*\z/, "")
191 segments = []
192 base = @context.dir.to_s.split("/").reject(&:empty?)
193 (base + url.split("/")).each do |seg|
194 next if seg.empty? || seg == "."
195 if seg == ".."
196 return nil if segments.empty?
197 segments.pop
198 else
199 segments << seg
200 end
201 end
202 return nil if segments.empty?
203 segments.join("/") + anchor.to_s
204 end
205 end
206 end