feat(read): rendered README + syntax-highlighted blob view

Make the web repo-browsing experience render code and Markdown instead of plain text — the core "calm place to read code" promise. Markdown (MarkdownRenderer service): - GFM via Redcarpet: tables, task lists, strikethrough, autolinks, footnotes - Rouge-highlighted fenced code; heading anchor links with unique slugs - relative links/images resolved against the repo + current ref - allow-list HTML sanitization (rails-html-sanitizer) + pruning of script/style/iframe blocks: README HTML/JS can never run on the app origin Blob view: - per-line Rouge highlighting extracted to SyntaxHighlighter, cached by blob SHA - large-file handling: skip highlight past 512KB, truncate display past 5k lines, never load files >5MB (view-raw escape hatch); binary/image preview - .md blobs render as Markdown by default, ?plain=1 shows source - line selection: click → #L120, shift-click → #L120-L140, highlight + URL sync + scroll-on-load; "Copy permalink" pinned to the commit SHA - copy buttons on code + a Clone HTTPS/SSH control on the repo page Raw endpoint: X-Content-Type-Options: nosniff so user content (HTML/SVG) can't execute as active content; images keep their real content-type. Specs (RSpec, matching the project convention): renderer/sanitizer, highlighter, the raw endpoint, and the rendered read experience end-to-end. brakeman.ignore documents the new arg-list Open3 calls as shell-safe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 30, 2026 at 19:18 UTC eda5423bd7423e069c89e1ed0146fed79aa124eb
16 files changed +1037 -72
app/assets/stylesheets/application.tailwind.css
+30
@@ -99,6 +99,36 @@
99 .prose-readme blockquote { @apply border-l-4 border-surface-500 pl-4 italic text-gray-400 my-4; }
100 .prose-readme hr { @apply border-surface-600 my-6; }
101 .prose-readme img { @apply max-w-full; }
102 + .prose-readme h4 { @apply text-base font-medium mb-2 mt-4; }
103 + .prose-readme h5 { @apply text-sm font-medium mb-2 mt-3; }
104 + .prose-readme h6 { @apply text-sm font-medium text-gray-400 mb-2 mt-3; }
105 + /* Highlighted fenced code from MarkdownRenderer renders as <pre class="highlight"> */
106 + .prose-readme pre.highlight { @apply p-4; }
107 + .prose-readme .task-list-item { @apply list-none -ml-6; }
108 + .prose-readme .task-list-item input { @apply mr-1.5 align-middle; }
109 + /* GitHub-style heading anchors: the "#" link is hidden until hover. */
110 + .prose-readme .heading-anchor {
111 + @apply opacity-0 no-underline text-gray-500 mr-2 -ml-6 inline-block w-4;
112 + transition: opacity 0.1s;
113 + }
114 + .prose-readme .heading-anchored:hover .heading-anchor { @apply opacity-100; }
115 +
116 + /* Blob view line selection */
117 + .line-row:target,
118 + .line-row.line-selected { @apply bg-brand-500/15; }
119 + .line-row:hover { @apply bg-brand-500/10; }
120 +
121 + .clone-menu > summary::-webkit-details-marker { display: none; }
122 + .btn-ghost.copied { @apply text-green-400; }
123 +
124 + /* Copy button injected into rendered Markdown code blocks */
125 + .code-copy-wrap { @apply relative; }
126 + .code-copy-btn {
127 + @apply absolute top-2 right-2 opacity-0 bg-surface-600 border border-surface-500
128 + rounded px-2 py-0.5 text-xs text-gray-300 hover:text-gray-100 transition-opacity;
129 + }
130 + .code-copy-wrap:hover .code-copy-btn { @apply opacity-100; }
131 + .code-copy-btn.copied { @apply text-green-400 opacity-100; }
132
133 .diff-add { @apply bg-green-900/30 text-green-300; }
134 .diff-remove { @apply bg-red-900/30 text-red-300; }
app/controllers/blobs_controller.rb
+104 -45
@@ -16,6 +16,18 @@ class BlobsController < ApplicationController
16 # is loaded through an <img> tag, which browsers sandbox.
17 PREVIEW_IMAGE_TYPES = RAW_IMAGE_TYPES.merge("svg" => "image/svg+xml").freeze
18
19 + MARKDOWN_EXTENSIONS = %w[md markdown mdown mkd].freeze
20 +
21 + # Above this many bytes a text file is shown as plain (un-highlighted) text:
22 + # tokenizing megabyte-scale files is the main source of render jank.
23 + MAX_HIGHLIGHT_BYTES = 512 * 1024
24 + # Above this many bytes we don't render Markdown — show source instead.
25 + MAX_MARKDOWN_BYTES = 512 * 1024
26 + # Above this we don't read the blob into memory at all; only "view raw" works.
27 + MAX_LOAD_BYTES = 5 * 1024 * 1024
28 + # Display cap so a huge file never renders tens of thousands of <tr>s.
29 + MAX_DISPLAY_LINES = 5_000
30 +
31 def show
32 # A file path like ".../app.js" or "...style.css" makes Rails negotiate a
33 # non-HTML format from the trailing extension — which has no template (→
@@ -29,52 +41,125 @@ class BlobsController < ApplicationController
41 @branch = params[:branch]
42 @file_path = params[:path]
43 @path_parts = @file_path.to_s.split("/").reject(&:blank?)
44 + @filename = File.basename(@file_path)
45 + @extension = File.extname(@filename).delete_prefix(".").downcase
46 + @plain = params[:plain].present?
47
33 - content = GitRepositoryService.file_content(@repository.disk_path, @branch, @file_path)
34 - if content.nil?
48 + @blob_sha = GitRepositoryService.blob_sha(@repository.disk_path, @branch, @file_path)
49 + @file_size = GitRepositoryService.blob_size(@repository.disk_path, @branch, @file_path)
50 + if @blob_sha.nil? || @file_size.nil?
51 render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
52 return
53 end
54
39 - @raw_content = content
40 - @filename = File.basename(@file_path)
41 - @extension = File.extname(@filename).delete_prefix(".")
55 @commit_count = GitRepositoryService.commit_count(@repository.disk_path, @branch)
43 - @is_binary = content.encoding != Encoding::UTF_8 || !content.valid_encoding? || binary_content?(content)
44 - @file_size = content.bytesize
56 + # Pin "copy permalink" to the commit the ref points at right now, so the
57 + # link keeps resolving to this exact content after the branch moves on.
58 + @commit_sha = GitRepositoryService.commit_sha(@repository.disk_path, @branch)
59 + @raw_path = repository_blob_raw_path(@owner.username, @repository.name, @branch, @file_path)
60
46 - @image_type = PREVIEW_IMAGE_TYPES[@extension.downcase]
61 + @image_type = PREVIEW_IMAGE_TYPES[@extension]
62 if @image_type
48 - # Embed the image inline as a data URI so it previews without a second
49 - # request. <img>-loaded content is sandboxed, so this is safe for SVG too.
50 - @image_data_uri = "data:#{@image_type};base64,#{[ content ].pack('m0')}"
51 - elsif !@is_binary
52 - @highlighted_lines = highlight_lines(content, @filename)
53 - @line_count = content.lines.count
63 + prepare_image
64 + else
65 + prepare_text
66 end
67
68 render :show
69 end
70
71 def raw
60 - @branch = params[:branch]
61 - @file_path = params[:path]
72 + branch = params[:branch]
73 + file_path = params[:path]
74
63 - content = GitRepositoryService.file_content(@repository.disk_path, @branch, @file_path)
75 + content = GitRepositoryService.file_content(@repository.disk_path, branch, file_path)
76 if content.nil?
77 head :not_found
78 return
79 end
80
69 - ext = File.extname(@file_path).delete_prefix(".").downcase
81 + ext = File.extname(file_path).delete_prefix(".").downcase
82 + # nosniff stops the browser from re-interpreting user content (e.g. an HTML
83 + # or SVG file served as text/plain) as active content on the app origin.
84 + response.headers["X-Content-Type-Options"] = "nosniff"
85 send_data content,
86 type: RAW_IMAGE_TYPES[ext] || "text/plain; charset=utf-8",
87 disposition: "inline",
73 - filename: File.basename(@file_path)
88 + filename: File.basename(file_path)
89 end
90
91 private
92
93 + def prepare_image
94 + if @file_size > MAX_LOAD_BYTES
95 + @too_large = true
96 + return
97 + end
98 + content = GitRepositoryService.file_content(@repository.disk_path, @branch, @file_path)
99 + # Embed inline as a data URI so it previews without a second request.
100 + # <img>-loaded content is sandboxed, so this is safe for SVG too.
101 + @image_data_uri = "data:#{@image_type};base64,#{[ content ].pack('m0')}"
102 + end
103 +
104 + def prepare_text
105 + if @file_size > MAX_LOAD_BYTES
106 + @too_large = true
107 + return
108 + end
109 +
110 + content = GitRepositoryService.file_content(@repository.disk_path, @branch, @file_path)
111 + if content.nil?
112 + render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
113 + return
114 + end
115 +
116 + @is_binary = content.encoding != Encoding::UTF_8 || !content.valid_encoding? || binary_content?(content)
117 + return if @is_binary
118 +
119 + if markdown? && !@plain && @file_size <= MAX_MARKDOWN_BYTES
120 + @markdown_html = render_markdown(content)
121 + return
122 + end
123 +
124 + @line_count = content.lines.count
125 + lines = highlighted_lines(content)
126 + if lines.length > MAX_DISPLAY_LINES
127 + @truncated = true
128 + lines = lines.first(MAX_DISPLAY_LINES)
129 + end
130 + @highlighted_lines = lines
131 + end
132 +
133 + def markdown?
134 + MARKDOWN_EXTENSIONS.include?(@extension)
135 + end
136 +
137 + # Highlighted line fragments, cached by blob SHA (content-addressed, so the
138 + # cache is shared across refs and never goes stale). Files past the highlight
139 + # budget are escaped as plain text to keep large views snappy.
140 + def highlighted_lines(content)
141 + if @file_size > MAX_HIGHLIGHT_BYTES
142 + @highlight_skipped = true
143 + return content.lines.map { |l| ERB::Util.html_escape(l.chomp) }.map(&:html_safe)
144 + end
145 +
146 + Rails.cache.fetch([ "blob-hl", @blob_sha ]) do
147 + SyntaxHighlighter.lines(content, filename: @filename)
148 + end.map(&:html_safe)
149 + end
150 +
151 + def render_markdown(content)
152 + context = MarkdownRenderer::Context.new(
153 + username: @owner.username,
154 + repository: @repository.name,
155 + ref: @branch,
156 + dir: @path_parts[0..-2].join("/")
157 + )
158 + Rails.cache.fetch([ "md", @blob_sha, context.ref, context.dir ]) do
159 + MarkdownRenderer.render(content, context: context)
160 + end.html_safe
161 + end
162 +
163 def load_owner
164 @owner = User.find_by!(username: params[:username])
165 rescue ActiveRecord::RecordNotFound
@@ -96,30 +181,4 @@ class BlobsController < ApplicationController
181 def binary_content?(content)
182 content.bytes.first(8192).include?(0)
183 end
99 -
100 - def highlight_lines(content, filename)
101 - lexer = Rouge::Lexer.guess(filename: filename, source: content)
102 - html_formatter = Rouge::Formatters::HTML.new
103 -
104 - # Tokenize the entire file at once (preserves stateful lexer context across
105 - # lines — e.g. multiline strings, heredocs) then split the token stream on
106 - # newline boundaries to get one HTML fragment per source line.
107 - lines = [[]]
108 - lexer.lex(content).each do |token, value|
109 - value.split(/(\n)/, -1).each do |part|
110 - if part == "\n"
111 - lines << []
112 - else
113 - lines.last << [token, part] unless part.empty?
114 - end
115 - end
116 - end
117 -
118 - # Drop a trailing empty entry when the file ends with a newline.
119 - lines.pop if lines.last&.empty?
120 -
121 - lines.map { |line_tokens| html_formatter.format(line_tokens).html_safe }
122 - rescue StandardError
123 - content.lines.map { |l| ERB::Util.html_escape(l.chomp).html_safe }
124 - end
184 end
app/controllers/repositories_controller.rb
+24 -17
@@ -69,9 +69,11 @@ class RepositoriesController < ApplicationController
69 @tree = GitRepositoryService.list_tree(@repository.disk_path, branch)
70 readme = GitRepositoryService.readme_content(@repository.disk_path, branch)
71 if readme
72 - @readme_html = render_markdown(readme[:content])
72 + @readme_html = render_markdown(readme[:content], file_path: readme[:filename], branch: branch)
73 @readme_filename = readme[:filename]
74 end
75 + @clone_https_url = clone_https_url(@owner.username, @repository.name)
76 + @clone_ssh_url = clone_ssh_url(@owner.username, @repository.name)
77 @commit_count = GitRepositoryService.commit_count(@repository.disk_path, branch)
78 @recent_commits = GitRepositoryService.commits(@repository.disk_path, branch, limit: 3)
79 end
@@ -136,7 +138,7 @@ class RepositoriesController < ApplicationController
138 # client-side tab switching.
139 def show_model(branch)
140 @card = @repository.model_card(branch: branch)
139 - @card_html = render_markdown(@card.body) if @card.body.present?
141 + @card_html = render_markdown(@card.body, file_path: "README.md", branch: branch) if @card.body.present?
142 @files = GitRepositoryService.model_files(@repository.disk_path, branch)
143 @total_size = @files.filter_map { |f| f[:size] }.sum
144 @commit_count = GitRepositoryService.commit_count(@repository.disk_path, branch)
@@ -199,21 +201,26 @@ class RepositoriesController < ApplicationController
201 end
202 end
203
202 - def render_markdown(text)
203 - renderer = Redcarpet::Render::HTML.new(
204 - hard_wrap: false,
205 - link_attributes: { target: "_blank", rel: "noopener noreferrer" },
206 - no_images: false,
207 - safe_links_only: true
204 + # Render Markdown to sanitized HTML, resolving relative links/images against
205 + # this repo at the current ref. Cached by the file's blob SHA + ref so the
206 + # heavy render only runs when the content (or relative-link base) changes.
207 + def render_markdown(text, file_path:, branch:)
208 + context = MarkdownRenderer::Context.new(
209 + username: @owner.username,
210 + repository: @repository.name,
211 + ref: branch,
212 + dir: File.dirname(file_path.to_s).then { |d| d == "." ? "" : d }
213 )
209 - markdown = Redcarpet::Markdown.new(
210 - renderer,
211 - autolink: true,
212 - tables: true,
213 - fenced_code_blocks: true,
214 - strikethrough: true,
215 - space_after_headers: true
216 - )
217 - markdown.render(text).html_safe
214 + sha = GitRepositoryService.blob_sha(@repository.disk_path, branch, file_path)
215 + cache_key = [ "md", sha || Digest::SHA1.hexdigest(text.to_s), context.username, context.repository, context.ref, context.dir ]
216 + Rails.cache.fetch(cache_key) { MarkdownRenderer.render(text, context: context) }.html_safe
217 + end
218 +
219 + def clone_https_url(username, reponame)
220 + "#{request.base_url}/#{username}/#{reponame}.git"
221 + end
222 +
223 + def clone_ssh_url(username, reponame)
224 + "git@#{request.host}:#{username}/#{reponame}.git"
225 end
226 end
app/javascript/controllers/clipboard_controller.js new
+59
@@ -0,0 +1,59 @@
1 +import { Controller } from "@hotwired/stimulus"
2 +
3 +// Copies text to the clipboard and flashes a "Copied" confirmation.
4 +//
5 +// Source of the text, in priority order:
6 +// 1. data-clipboard-text-value on the controller element
7 +// 2. the textContent of the element matched by the "source" target
8 +//
9 +// <div data-controller="clipboard" data-clipboard-text-value="git clone …">
10 +// <button data-action="clipboard#copy">Copy</button>
11 +// </div>
12 +export default class extends Controller {
13 + static targets = ["button", "source"]
14 + static values = { text: String, label: { type: String, default: "Copied" } }
15 +
16 + copy(event) {
17 + event.preventDefault()
18 + let text = ""
19 + if (this.hasTextValue && this.textValue) {
20 + text = this.textValue
21 + } else if (this.sourceTargets.length) {
22 + // Multiple sources (e.g. one per code line) join with newlines so a whole
23 + // file copies back as the original text.
24 + text = this.sourceTargets.map((el) => el.textContent).join("\n")
25 + }
26 + if (!text) return
27 +
28 + this.#write(text).then(() => this.#flash(event.currentTarget))
29 + }
30 +
31 + async #write(text) {
32 + try {
33 + await navigator.clipboard.writeText(text)
34 + } catch {
35 + // Fallback for non-secure contexts where the async clipboard API is absent.
36 + const ta = document.createElement("textarea")
37 + ta.value = text
38 + ta.style.position = "fixed"
39 + ta.style.opacity = "0"
40 + document.body.appendChild(ta)
41 + ta.select()
42 + try { document.execCommand("copy") } catch { /* no-op */ }
43 + ta.remove()
44 + }
45 + }
46 +
47 + #flash(button) {
48 + if (!button) return
49 + const original = button.dataset.originalLabel ?? button.textContent
50 + button.dataset.originalLabel = original
51 + button.textContent = this.labelValue
52 + button.classList.add("copied")
53 + clearTimeout(this._timer)
54 + this._timer = setTimeout(() => {
55 + button.textContent = original
56 + button.classList.remove("copied")
57 + }, 1500)
58 + }
59 +}
app/javascript/controllers/code_copy_controller.js new
+36
@@ -0,0 +1,36 @@
1 +import { Controller } from "@hotwired/stimulus"
2 +
3 +// Injects a "Copy" button into every fenced code block inside rendered
4 +// Markdown (README / .md). Attach to the prose container:
5 +// <div class="prose-readme" data-controller="code-copy">…</div>
6 +export default class extends Controller {
7 + connect() {
8 + this.element.querySelectorAll("pre.highlight").forEach((pre) => {
9 + if (pre.querySelector(".code-copy-btn")) return
10 + pre.classList.add("code-copy-wrap")
11 +
12 + const btn = document.createElement("button")
13 + btn.type = "button"
14 + btn.className = "code-copy-btn"
15 + btn.textContent = "Copy"
16 + btn.addEventListener("click", () => this.#copy(pre, btn))
17 + pre.appendChild(btn)
18 + })
19 + }
20 +
21 + async #copy(pre, btn) {
22 + const code = pre.querySelector("code")?.textContent ?? pre.textContent
23 + try {
24 + await navigator.clipboard.writeText(code)
25 + } catch {
26 + return
27 + }
28 + const original = btn.textContent
29 + btn.textContent = "Copied"
30 + btn.classList.add("copied")
31 + setTimeout(() => {
32 + btn.textContent = original
33 + btn.classList.remove("copied")
34 + }, 1500)
35 + }
36 +}
app/javascript/controllers/line_selection_controller.js new
+105
@@ -0,0 +1,105 @@
1 +import { Controller } from "@hotwired/stimulus"
2 +
3 +// GitHub-style line selection for the blob view.
4 +//
5 +// click a line number -> #L120
6 +// shift-click another -> #L120-L140 (range, in either direction)
7 +// load with a #L.. hash -> highlight + scroll into view
8 +// "copy permalink" action -> SHA-pinned URL + the current selection
9 +//
10 +// Markup contract:
11 +// <table data-controller="line-selection"
12 +// data-line-selection-permalink-base-value="/u/r/blob/<sha>/path">
13 +// <tr id="L1" data-line-selection-target="row"> … <a data-action="…">1</a> …
14 +export default class extends Controller {
15 + static targets = ["row"]
16 + static values = { permalinkBase: String }
17 +
18 + connect() {
19 + this.start = null
20 + this.end = null
21 + this.#applyFromHash()
22 + this._onHashChange = () => this.#applyFromHash()
23 + window.addEventListener("hashchange", this._onHashChange)
24 + }
25 +
26 + disconnect() {
27 + window.removeEventListener("hashchange", this._onHashChange)
28 + }
29 +
30 + // Bound to each line-number anchor via data-action="line-selection#select".
31 + select(event) {
32 + event.preventDefault()
33 + const line = Number(event.params.line)
34 + if (!line) return
35 +
36 + if (event.shiftKey && this.start) {
37 + this.end = line
38 + } else {
39 + this.start = line
40 + this.end = line
41 + }
42 + this.#render()
43 + this.#updateHash()
44 + }
45 +
46 + copyPermalink(event) {
47 + event.preventDefault()
48 + const base = this.permalinkBaseValue
49 + if (!base) return
50 + const origin = window.location.origin
51 + const url = `${origin}${base}${this.#hash()}`
52 + navigator.clipboard?.writeText(url).catch(() => {})
53 + this.#flash(event.currentTarget)
54 + }
55 +
56 + #applyFromHash() {
57 + const m = window.location.hash.match(/^#L(\d+)(?:-L?(\d+))?$/)
58 + if (!m) return
59 + this.start = Number(m[1])
60 + this.end = m[2] ? Number(m[2]) : this.start
61 + this.#render()
62 + const first = this.#row(Math.min(this.start, this.end))
63 + if (first) first.scrollIntoView({ block: "center" })
64 + }
65 +
66 + #render() {
67 + const [lo, hi] = this.#ordered()
68 + this.rowTargets.forEach((row) => {
69 + const n = this.#lineOf(row)
70 + row.classList.toggle("line-selected", lo !== null && n >= lo && n <= hi)
71 + })
72 + }
73 +
74 + #updateHash() {
75 + history.replaceState(null, "", this.#hash())
76 + }
77 +
78 + #hash() {
79 + const [lo, hi] = this.#ordered()
80 + if (lo === null) return ""
81 + return lo === hi ? `#L${lo}` : `#L${lo}-L${hi}`
82 + }
83 +
84 + #ordered() {
85 + if (!this.start) return [null, null]
86 + return [Math.min(this.start, this.end), Math.max(this.start, this.end)]
87 + }
88 +
89 + #row(line) {
90 + return this.rowTargets.find((r) => this.#lineOf(r) === line)
91 + }
92 +
93 + #lineOf(row) {
94 + return Number(row.dataset.line ?? row.id.replace(/^L/, ""))
95 + }
96 +
97 + #flash(el) {
98 + if (!el) return
99 + const original = el.dataset.originalLabel ?? el.textContent
100 + el.dataset.originalLabel = original
101 + el.textContent = "Copied"
102 + clearTimeout(this._t)
103 + this._t = setTimeout(() => { el.textContent = original }, 1500)
104 + }
105 +}
app/services/git_repository_service.rb
+21
@@ -87,6 +87,27 @@ class GitRepositoryService
87 out
88 end
89
90 + # Object id of the blob at <ref>:<file_path>. Stable for identical content, so
91 + # it's the natural cache key for rendered/highlighted output. nil if missing.
92 + def self.blob_sha(path, branch, file_path)
93 + out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", "--quiet", "#{branch}:#{file_path}")
94 + status.success? ? out.strip.presence : nil
95 + end
96 +
97 + # Byte size of the blob without loading it into memory — lets the blob view
98 + # decide up front whether a file is too large to highlight or render.
99 + def self.blob_size(path, branch, file_path)
100 + out, _err, status = Open3.capture3("git", "--git-dir", path, "cat-file", "-s", "#{branch}:#{file_path}")
101 + status.success? ? out.strip.to_i : nil
102 + end
103 +
104 + # Full commit SHA a ref currently points at. Permalinks pin to this so they
105 + # survive the branch moving on.
106 + def self.commit_sha(path, ref)
107 + out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", "--quiet", "#{ref}^{commit}")
108 + status.success? ? out.strip.presence : nil
109 + end
110 +
111 def self.commits(path, branch, limit: 20, offset: 0)
112 format = "%H%x00%h%x00%s%x00%an%x00%ae%x00%ad%x00%cn"
113 out, _err, status = Open3.capture3(
app/services/markdown_renderer.rb new
+206
@@ -0,0 +1,206 @@
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
app/services/syntax_highlighter.rb new
+41
@@ -0,0 +1,41 @@
1 +require "rouge"
2 +require "cgi"
3 +
4 +# Server-side syntax highlighting for the blob view. Returns one HTML fragment
5 +# per source line so each line can be numbered and individually anchored.
6 +#
7 +# The whole file is tokenized in a single pass (so stateful constructs such as
8 +# heredocs and multi-line strings highlight correctly), then the token stream
9 +# is split on newline boundaries. Unknown languages fall back to a plain-text
10 +# lexer; any lexer error falls back to HTML-escaped plain lines.
11 +class SyntaxHighlighter
12 + def self.lines(content, filename:)
13 + new(content, filename: filename).lines
14 + end
15 +
16 + def initialize(content, filename:)
17 + @content = content
18 + @filename = filename
19 + end
20 +
21 + def lines
22 + lexer = Rouge::Lexer.guess(filename: @filename, source: @content) || Rouge::Lexers::PlainText
23 + formatter = Rouge::Formatters::HTML.new
24 +
25 + rows = [ [] ]
26 + lexer.lex(@content).each do |token, value|
27 + value.split(/(\n)/, -1).each do |part|
28 + if part == "\n"
29 + rows << []
30 + elsif !part.empty?
31 + rows.last << [ token, part ]
32 + end
33 + end
34 + end
35 + rows.pop if rows.last&.empty?
36 +
37 + rows.map { |tokens| formatter.format(tokens) }
38 + rescue StandardError
39 + @content.lines.map { |l| CGI.escapeHTML(l.chomp) }
40 + end
41 +end
app/views/blobs/show.html.erb
+48 -9
@@ -47,8 +47,10 @@
47 </div>
48
49 <div class="max-w-6xl mx-auto px-4 sm:px-6 py-6">
50 - <div class="card">
51 - <div class="px-4 py-2.5 border-b border-surface-600 flex items-center justify-between bg-surface-600">
50 + <% permalink_path = @commit_sha ? repository_blob_path(@owner.username, @repository.name, @commit_sha, @file_path) : nil %>
51 + <div class="card" data-controller="clipboard line-selection"
52 + <% if permalink_path %>data-line-selection-permalink-base-value="<%= permalink_path %>"<% end %>>
53 + <div class="px-4 py-2.5 border-b border-surface-600 flex items-center justify-between gap-3 bg-surface-600">
54 <div class="flex items-center gap-3 text-xs text-gray-400">
55 <span class="font-mono badge-gray"><%= @extension.presence || "text" %></span>
56 <% if @line_count %>
@@ -57,12 +59,31 @@
59 <span><%= number_to_human_size(@file_size) %></span>
60 </div>
61 <div class="flex items-center gap-2">
60 - <%= link_to "Raw", repository_blob_raw_path(@owner.username, @repository.name, @branch, @file_path),
61 - class: "btn-ghost text-xs py-1 px-2.5", target: "_blank" %>
62 + <% if @markdown_html %>
63 + <%= link_to "View source", repository_blob_path(@owner.username, @repository.name, @branch, @file_path, plain: 1),
64 + class: "btn-ghost text-xs py-1 px-2.5" %>
65 + <% elsif @plain && BlobsController::MARKDOWN_EXTENSIONS.include?(@extension) %>
66 + <%= link_to "Rendered", repository_blob_path(@owner.username, @repository.name, @branch, @file_path),
67 + class: "btn-ghost text-xs py-1 px-2.5" %>
68 + <% end %>
69 + <% if @highlighted_lines && permalink_path %>
70 + <button type="button" data-action="line-selection#copyPermalink"
71 + class="btn-ghost text-xs py-1 px-2.5">Copy permalink</button>
72 + <button type="button" data-action="clipboard#copy"
73 + class="btn-ghost text-xs py-1 px-2.5">Copy</button>
74 + <% end %>
75 + <%= link_to "Raw", @raw_path, class: "btn-ghost text-xs py-1 px-2.5", target: "_blank", rel: "noopener" %>
76 </div>
77 </div>
78
65 - <% if @image_type %>
79 + <% if @too_large %>
80 + <div class="px-6 py-10 text-center text-sm text-gray-400">
81 + This file is <%= number_to_human_size(@file_size) %> — too large to display.
82 + <div class="mt-4">
83 + <%= link_to "View raw", @raw_path, class: "btn-secondary text-xs py-1 px-3", target: "_blank", rel: "noopener" %>
84 + </div>
85 + </div>
86 + <% elsif @image_type %>
87 <div class="px-6 py-10 flex justify-center bg-surface-800">
88 <img src="<%= @image_data_uri %>" alt="<%= @filename %>"
89 class="max-w-full h-auto rounded border border-surface-600" />
@@ -70,22 +91,40 @@
91 <% elsif @is_binary %>
92 <div class="px-6 py-10 text-center text-sm text-gray-400">
93 Binary file — <%= number_to_human_size(@file_size) %>
94 + <div class="mt-4">
95 + <%= link_to "View raw", @raw_path, class: "btn-secondary text-xs py-1 px-3", target: "_blank", rel: "noopener" %>
96 + </div>
97 </div>
98 + <% elsif @markdown_html %>
99 + <div class="px-6 py-6 prose-readme" data-controller="code-copy"><%= @markdown_html %></div>
100 <% else %>
101 + <% if @highlight_skipped %>
102 + <div class="px-4 py-2 text-xs text-amber-300/80 bg-amber-900/10 border-b border-surface-600">
103 + Large file — syntax highlighting disabled.
104 + </div>
105 + <% end %>
106 <div class="code-container rounded-none border-0 relative overflow-x-auto">
107 <table class="w-full">
108 <tbody class="highlight">
109 <% @highlighted_lines.each_with_index do |highlighted_line, i| %>
79 - <tr class="hover:bg-brand-500/10">
80 - <td class="text-right text-gray-500 pr-4 pl-4 py-0 select-none w-10 text-xs border-r border-surface-600 align-top font-mono" id="L<%= i+1 %>">
81 - <a href="#L<%= i+1 %>" class="hover:text-brand-500"><%= i+1 %></a>
110 + <% n = i + 1 %>
111 + <tr id="L<%= n %>" data-line="<%= n %>" data-line-selection-target="row" class="line-row">
112 + <td class="line-number text-right text-gray-500 pr-4 pl-4 py-0 select-none w-10 text-xs border-r border-surface-600 align-top font-mono">
113 + <a href="#L<%= n %>" data-action="line-selection#select"
114 + data-line-selection-line-param="<%= n %>" class="hover:text-brand-500"><%= n %></a>
115 </td>
83 - <td class="pl-4 pr-4 py-0 font-mono text-xs leading-5 whitespace-pre text-gray-200"><%= highlighted_line %></td>
116 + <td data-clipboard-target="source" class="pl-4 pr-4 py-0 font-mono text-xs leading-5 whitespace-pre text-gray-200"><%= highlighted_line %></td>
117 </tr>
118 <% end %>
119 </tbody>
120 </table>
121 </div>
122 + <% if @truncated %>
123 + <div class="px-4 py-3 text-xs text-gray-400 border-t border-surface-600 text-center">
124 + Showing first <%= number_with_delimiter(@highlighted_lines.length) %> of <%= number_with_delimiter(@line_count) %> lines.
125 + <%= link_to "View raw", @raw_path, class: "text-brand-500 hover:underline", target: "_blank", rel: "noopener" %>
126 + </div>
127 + <% end %>
128 <% end %>
129 </div>
130 </div>
app/views/repositories/show.html.erb
+24 -1
@@ -107,6 +107,29 @@
107 </svg>
108 <%= number_with_delimiter(@commit_count) %> commits
109 <% end %>
110 +
111 + <details class="clone-menu relative">
112 + <summary class="btn-secondary py-1 px-3 text-xs flex items-center gap-1.5 cursor-pointer list-none">
113 + <svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
114 + <path d="M2.75 2.5a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h10.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75ZM4 6.25A.75.75 0 0 1 4.75 5.5h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 4 6.25Zm.75 2.25a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5Z"/>
115 + </svg>
116 + Clone
117 + </summary>
118 + <div class="absolute right-0 mt-2 w-80 z-10 card p-3 space-y-3 text-left">
119 + <% [["HTTPS", @clone_https_url], ["SSH", @clone_ssh_url]].each do |label, url| %>
120 + <div data-controller="clipboard" data-clipboard-text-value="<%= url %>">
121 + <div class="text-[11px] uppercase tracking-wide text-gray-500 mb-1"><%= label %></div>
122 + <div class="flex items-center gap-2">
123 + <input type="text" readonly value="<%= url %>"
124 + class="flex-1 bg-surface-800 border border-surface-600 rounded px-2 py-1 text-xs font-mono text-gray-200 focus:outline-none"
125 + onclick="this.select()">
126 + <button type="button" data-action="clipboard#copy"
127 + class="btn-ghost text-xs py-1 px-2.5 shrink-0">Copy</button>
128 + </div>
129 + </div>
130 + <% end %>
131 + </div>
132 + </details>
133 </div>
134 </div>
135
@@ -140,7 +163,7 @@
163 </svg>
164 <span class="text-xs font-medium text-gray-400"><%= @readme_filename %></span>
165 </div>
143 - <div class="px-6 py-6 prose-readme">
166 + <div class="px-6 py-6 prose-readme" data-controller="code-copy">
167 <%= @readme_html %>
168 </div>
169 </div>
config/brakeman.ignore new
+35
@@ -0,0 +1,35 @@
1 +{
2 + "ignored_warnings": [
3 + {
4 + "warning_type": "Command Injection",
5 + "warning_code": 14,
6 + "fingerprint": "1dbb9af6b074159eced6b9d2536bec26667c240f09e3a5bb49285b45775cca4b",
7 + "check_name": "Execute",
8 + "message": "Possible command injection",
9 + "file": "app/services/git_repository_service.rb",
10 + "line": 93,
11 + "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref/path is passed to git as a single literal argv entry and cannot inject shell commands."
12 + },
13 + {
14 + "warning_type": "Command Injection",
15 + "warning_code": 14,
16 + "fingerprint": "0a470c5a46cd5b8ac1b0487e651f761caeeb961ac9bc408a33d2ca89257085c3",
17 + "check_name": "Execute",
18 + "message": "Possible command injection",
19 + "file": "app/services/git_repository_service.rb",
20 + "line": 100,
21 + "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref/path is passed to git as a single literal argv entry and cannot inject shell commands."
22 + },
23 + {
24 + "warning_type": "Command Injection",
25 + "warning_code": 14,
26 + "fingerprint": "586ef55597b40deec327d690a446903cdfb89a65dfeb7962ad0f6f603d16f9fd",
27 + "check_name": "Execute",
28 + "message": "Possible command injection",
29 + "file": "app/services/git_repository_service.rb",
30 + "line": 107,
31 + "note": "False positive: Open3.capture3 is called with a separate argument list (no shell), so the interpolated ref is passed to git as a single literal argv entry and cannot inject shell commands."
32 + }
33 + ],
34 + "brakeman_version": "8.0.5"
35 +}
spec/requests/blob_view_spec.rb new
+78
@@ -0,0 +1,78 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +require "tmpdir"
5 +require "fileutils"
6 +
7 +# End-to-end checks for the rendered read experience: a README renders (not
8 +# raw) on the repo landing page, source files get syntax highlighting + line
9 +# numbers, .md blobs render, the permalink pins to a commit SHA, and malicious
10 +# HTML in a README is neutralised.
11 +RSpec.describe "Blob view", type: :request do
12 + around do |example|
13 + Dir.mktmpdir("blob-view-spec") do |tmp|
14 + @repo_dir = File.join(tmp, "demo.git")
15 + build_repo(@repo_dir,
16 + "README.md" => "# Demo Project\n\nHello <script>alert('xss')</script> world\n\n- [x] done\n- [ ] todo\n",
17 + "main.rb" => "def greet\n puts 'hi'\nend\n")
18 + @sha = `git --git-dir #{@repo_dir} rev-parse main`.strip
19 + example.run
20 + end
21 + end
22 +
23 + let(:user) { User.create!(smbcloud_id: 123_321, email: "blob-spec@example.com", username: "blobspec") }
24 + let!(:repo) { user.repositories.create!(name: "demo", disk_path: @repo_dir, default_branch: "main") }
25 +
26 + it "renders the README as HTML on the repo landing page" do
27 + get "/blobspec/demo"
28 + expect(response).to have_http_status(:success)
29 + expect(response.body).to include('<div class="px-6 py-6 prose-readme"')
30 + expect(response.body).to match(%r{<h1[^>]*>.*Demo Project}m)
31 + end
32 +
33 + it "neutralises malicious HTML in the README" do
34 + get "/blobspec/demo"
35 + # The payload is gone entirely; the only <script> tags left are the page
36 + # layout's own (JSON-LD / importmap), never the README's.
37 + expect(response.body).not_to include("alert('xss')")
38 + rendered = response.body[/prose-readme.*?<\/div>/m]
39 + expect(rendered).not_to include("<script")
40 + end
41 +
42 + it "shows syntax highlighting and per-line anchors for a source file" do
43 + get "/blobspec/demo/blob/main/main.rb"
44 + expect(response).to have_http_status(:success)
45 + expect(response.body).to include('id="L1"').and include('id="L2"')
46 + expect(response.body).to include('class="highlight"')
47 + end
48 +
49 + it "pins the copy-permalink base to the commit SHA, not the branch" do
50 + get "/blobspec/demo/blob/main/main.rb"
51 + expect(response.body).to include(
52 + %(data-line-selection-permalink-base-value="/blobspec/demo/blob/#{@sha}/main.rb")
53 + )
54 + end
55 +
56 + it "renders an .md blob as Markdown by default and as source with ?plain" do
57 + get "/blobspec/demo/blob/main/README.md"
58 + expect(response.body).to match(%r{<h1[^>]*>.*Demo Project}m)
59 +
60 + get "/blobspec/demo/blob/main/README.md?plain=1"
61 + expect(response.body).to include('id="L1"')
62 + end
63 +
64 + def build_repo(bare_path, files)
65 + FileUtils.mkdir_p(bare_path)
66 + system("git", "init", "--bare", bare_path, exception: true)
67 + Dir.mktmpdir do |work|
68 + system("git", "-C", work, "init", "-b", "main", exception: true)
69 + system("git", "-C", work, "config", "user.email", "t@example.com", exception: true)
70 + system("git", "-C", work, "config", "user.name", "Test", exception: true)
71 + files.each { |name, content| File.write(File.join(work, name), content) }
72 + system("git", "-C", work, "add", ".", exception: true)
73 + system("git", "-C", work, "commit", "-m", "seed", exception: true)
74 + system("git", "-C", work, "remote", "add", "origin", bare_path, exception: true)
75 + system("git", "-C", work, "push", "origin", "main", exception: true)
76 + end
77 + end
78 +end
spec/requests/raw_blob_spec.rb new
+75
@@ -0,0 +1,75 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +require "tmpdir"
5 +require "fileutils"
6 +require "base64"
7 +
8 +# The /raw endpoint is what install scripts, badges, and CI hit, so it must
9 +# return the exact bytes with a sensible content-type and the nosniff guard
10 +# that stops the browser from running user content as active content on the
11 +# app origin.
12 +RSpec.describe "Raw blob", type: :request do
13 + around do |example|
14 + Dir.mktmpdir("raw-blob-spec") do |tmp|
15 + @repo_dir = File.join(tmp, "demo.git")
16 + build_repo(@repo_dir,
17 + "hello.txt" => "plain bytes\n",
18 + "page.html" => "<h1>hi</h1>\n",
19 + "logo.png" => png_bytes)
20 + example.run
21 + end
22 + end
23 +
24 + let(:user) { User.create!(smbcloud_id: 987_654, email: "raw-spec@example.com", username: "rawspec") }
25 + let!(:repo) { user.repositories.create!(name: "demo", disk_path: @repo_dir, default_branch: "main") }
26 +
27 + it "returns exact bytes with text/plain and nosniff for a text file" do
28 + get "/rawspec/demo/raw/main/hello.txt"
29 + expect(response).to have_http_status(:success)
30 + expect(response.body).to eq("plain bytes\n")
31 + expect(response.media_type).to match(%r{text/plain})
32 + expect(response.headers["X-Content-Type-Options"]).to eq("nosniff")
33 + end
34 +
35 + it "serves HTML files as text/plain so they cannot run as active content" do
36 + get "/rawspec/demo/raw/main/page.html"
37 + expect(response).to have_http_status(:success)
38 + expect(response.media_type).to match(%r{text/plain})
39 + expect(response.headers["X-Content-Type-Options"]).to eq("nosniff")
40 + end
41 +
42 + it "serves images with their real content-type" do
43 + get "/rawspec/demo/raw/main/logo.png"
44 + expect(response).to have_http_status(:success)
45 + expect(response.media_type).to eq("image/png")
46 + expect(response.headers["X-Content-Type-Options"]).to eq("nosniff")
47 + end
48 +
49 + it "returns 404 for a missing file" do
50 + get "/rawspec/demo/raw/main/does-not-exist.txt"
51 + expect(response).to have_http_status(:not_found)
52 + end
53 +
54 + def build_repo(bare_path, files)
55 + FileUtils.mkdir_p(bare_path)
56 + system("git", "init", "--bare", bare_path, exception: true)
57 + Dir.mktmpdir do |work|
58 + system("git", "-C", work, "init", "-b", "main", exception: true)
59 + system("git", "-C", work, "config", "user.email", "t@example.com", exception: true)
60 + system("git", "-C", work, "config", "user.name", "Test", exception: true)
61 + files.each { |name, content| File.binwrite(File.join(work, name), content) }
62 + system("git", "-C", work, "add", ".", exception: true)
63 + system("git", "-C", work, "commit", "-m", "seed", exception: true)
64 + system("git", "-C", work, "remote", "add", "origin", bare_path, exception: true)
65 + system("git", "-C", work, "push", "origin", "main", exception: true)
66 + end
67 + end
68 +
69 + # Smallest valid 1x1 PNG.
70 + def png_bytes
71 + Base64.decode64(
72 + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
73 + )
74 + end
75 +end
spec/services/markdown_renderer_spec.rb new
+114
@@ -0,0 +1,114 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +
5 +RSpec.describe MarkdownRenderer do
6 + def render(md, context: nil)
7 + described_class.render(md, context: context)
8 + end
9 +
10 + describe "sanitization (the security boundary)" do
11 + it "strips <script> tags and their contents" do
12 + html = render("Hello\n\n<script>alert('xss')</script>")
13 + expect(html).not_to include("<script")
14 + expect(html).not_to include("alert('xss')")
15 + end
16 +
17 + it "strips <iframe> elements" do
18 + expect(render("<iframe src='https://evil.example'></iframe>")).not_to include("<iframe")
19 + end
20 +
21 + it "strips inline event handlers" do
22 + html = render("<img src='x' onerror='alert(1)'>")
23 + expect(html).not_to include("onerror")
24 + expect(html).not_to include("alert(1)")
25 + end
26 +
27 + it "strips javascript: scheme links" do
28 + expect(render("[click](javascript:alert(1))")).not_to include("javascript:")
29 + end
30 +
31 + it "keeps safe http(s) links" do
32 + expect(render("[siGit](https://sigit.si)")).to include('href="https://sigit.si"')
33 + end
34 + end
35 +
36 + describe "GitHub-Flavored Markdown" do
37 + it "renders tables" do
38 + html = render("| a | b |\n|---|---|\n| 1 | 2 |")
39 + expect(html).to include("<table").and include("<th").and include("<td")
40 + end
41 +
42 + it "renders task lists as disabled checkboxes" do
43 + html = render("- [ ] todo\n- [x] done")
44 + expect(html.scan(/<input[^>]*type="checkbox"/).length).to eq(2)
45 + expect(html).to include("checked")
46 + expect(html).to include("task-list-item")
47 + end
48 +
49 + it "gives headings anchor ids and links" do
50 + html = render("# Hello World")
51 + expect(html).to include('id="hello-world"').and include('href="#hello-world"')
52 + end
53 +
54 + it "makes duplicate heading ids unique" do
55 + html = render("# Title\n\n# Title")
56 + expect(html).to include('id="title"').and include('id="title-1"')
57 + end
58 +
59 + it "syntax-highlights fenced code" do
60 + html = render("```ruby\nputs 1\n```")
61 + expect(html).to include('class="highlight code-block"').and include("<span")
62 + end
63 +
64 + it "autolinks bare URLs" do
65 + expect(render("see https://sigit.si for more")).to include('href="https://sigit.si"')
66 + end
67 + end
68 +
69 + describe "relative link/image resolution" do
70 + let(:ctx) { MarkdownRenderer::Context.new(username: "sigit", repository: "demo", ref: "main", dir: dir) }
71 + let(:dir) { "" }
72 +
73 + it "resolves a relative link to the blob view" do
74 + expect(render("[docs](docs/guide.md)", context: ctx))
75 + .to include('href="/sigit/demo/blob/main/docs/guide.md"')
76 + end
77 +
78 + it "resolves a relative image to the raw endpoint" do
79 + expect(render("![logo](assets/logo.png)", context: ctx))
80 + .to include('src="/sigit/demo/raw/main/assets/logo.png"')
81 + end
82 +
83 + context "from a nested directory" do
84 + let(:dir) { "docs" }
85 +
86 + it "resolves against the current directory" do
87 + expect(render("[sibling](other.md)", context: ctx))
88 + .to include('href="/sigit/demo/blob/main/docs/other.md"')
89 + end
90 + end
91 +
92 + context "with parent traversal" do
93 + let(:dir) { "docs/api" }
94 +
95 + it "collapses .. against the current directory" do
96 + expect(render("[up](../intro.md)", context: ctx))
97 + .to include('href="/sigit/demo/blob/main/docs/intro.md"')
98 + end
99 + end
100 +
101 + it "leaves absolute URLs untouched" do
102 + expect(render("[x](https://example.com/a)", context: ctx)).to include('href="https://example.com/a"')
103 + end
104 +
105 + it "leaves in-page anchors untouched" do
106 + expect(render("[top](#intro)", context: ctx)).to include('href="#intro"')
107 + end
108 + end
109 +
110 + it "returns an empty string for blank input" do
111 + expect(render("")).to eq("")
112 + expect(render(nil)).to eq("")
113 + end
114 +end
spec/services/syntax_highlighter_spec.rb new
+37
@@ -0,0 +1,37 @@
1 +# frozen_string_literal: true
2 +
3 +require "rails_helper"
4 +
5 +RSpec.describe SyntaxHighlighter do
6 + it "returns one fragment per source line" do
7 + expect(described_class.lines("def a\n 1\nend\n", filename: "a.rb").length).to eq(3)
8 + end
9 +
10 + it "does not emit a trailing blank line when the file ends with a newline" do
11 + expect(described_class.lines("one\ntwo\n", filename: "f.txt").length).to eq(2)
12 + end
13 +
14 + it "counts the final line when there is no trailing newline" do
15 + expect(described_class.lines("one\ntwo", filename: "f.txt").length).to eq(2)
16 + end
17 +
18 + it "highlights a known language" do
19 + expect(described_class.lines("puts 'hi'\n", filename: "x.rb").first).to include("<span")
20 + end
21 +
22 + it "falls back to plain text for an unknown extension" do
23 + lines = described_class.lines("just text\n", filename: "x.unknownext")
24 + expect(lines.length).to eq(1)
25 + expect(lines.first).to include("just text")
26 + end
27 +
28 + it "escapes HTML in the content" do
29 + line = described_class.lines("<script>\n", filename: "x.unknownext").first
30 + expect(line).not_to include("<script>")
31 + expect(line).to include("&lt;script&gt;")
32 + end
33 +
34 + it "handles empty content" do
35 + expect(described_class.lines("", filename: "x.txt")).to eq([])
36 + end
37 +end