main
rb 185 lines 6.62 KB
Raw
1 class BlobsController < ApplicationController
2 before_action :load_owner
3 before_action :load_repository
4 before_action :ensure_can_read!
5
6 # Raster image types served inline from the raw endpoint with their real
7 # content type. SVG is intentionally excluded — serving it inline could
8 # execute embedded scripts on direct navigation.
9 RAW_IMAGE_TYPES = {
10 "png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg",
11 "gif" => "image/gif", "webp" => "image/webp", "avif" => "image/avif",
12 "bmp" => "image/bmp", "ico" => "image/x-icon"
13 }.freeze
14
15 # Types previewed in the blob view via a data URI. SVG is safe here because it
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 (→
34 # UnknownFormat) and, for ":js", also skips the HTML layout. Pinning the view
35 # lookup formats governs both template and layout resolution; request.format
36 # sets the response content type. Together they make any file type render as
37 # a full, styled HTML page.
38 request.format = :html
39 lookup_context.formats = [ :html ]
40
41 @branch = params[:branch]
42 @branches = GitRepositoryService.branches(@repository.disk_path) rescue []
43 @file_path = params[:path]
44 @path_parts = @file_path.to_s.split("/").reject(&:blank?)
45 @filename = File.basename(@file_path)
46 @extension = File.extname(@filename).delete_prefix(".").downcase
47 @plain = params[:plain].present?
48
49 @blob_sha = GitRepositoryService.blob_sha(@repository.disk_path, @branch, @file_path)
50 @file_size = GitRepositoryService.blob_size(@repository.disk_path, @branch, @file_path)
51 if @blob_sha.nil? || @file_size.nil?
52 render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
53 return
54 end
55
56 @commit_count = GitRepositoryService.commit_count(@repository.disk_path, @branch)
57 # Pin "copy permalink" to the commit the ref points at right now, so the
58 # link keeps resolving to this exact content after the branch moves on.
59 @commit_sha = GitRepositoryService.commit_sha(@repository.disk_path, @branch)
60 @raw_path = repository_blob_raw_path(@owner.username, @repository.name, @branch, @file_path)
61
62 @image_type = PREVIEW_IMAGE_TYPES[@extension]
63 if @image_type
64 prepare_image
65 else
66 prepare_text
67 end
68
69 render :show
70 end
71
72 def raw
73 branch = params[:branch]
74 file_path = params[:path]
75
76 content = GitRepositoryService.file_content(@repository.disk_path, branch, file_path)
77 if content.nil?
78 head :not_found
79 return
80 end
81
82 ext = File.extname(file_path).delete_prefix(".").downcase
83 # nosniff stops the browser from re-interpreting user content (e.g. an HTML
84 # or SVG file served as text/plain) as active content on the app origin.
85 response.headers["X-Content-Type-Options"] = "nosniff"
86 send_data content,
87 type: RAW_IMAGE_TYPES[ext] || "text/plain; charset=utf-8",
88 disposition: "inline",
89 filename: File.basename(file_path)
90 end
91
92 private
93
94 def prepare_image
95 if @file_size > MAX_LOAD_BYTES
96 @too_large = true
97 return
98 end
99 content = GitRepositoryService.file_content(@repository.disk_path, @branch, @file_path)
100 # Embed inline as a data URI so it previews without a second request.
101 # <img>-loaded content is sandboxed, so this is safe for SVG too.
102 @image_data_uri = "data:#{@image_type};base64,#{[ content ].pack('m0')}"
103 end
104
105 def prepare_text
106 if @file_size > MAX_LOAD_BYTES
107 @too_large = true
108 return
109 end
110
111 content = GitRepositoryService.file_content(@repository.disk_path, @branch, @file_path)
112 if content.nil?
113 render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
114 return
115 end
116
117 @is_binary = content.encoding != Encoding::UTF_8 || !content.valid_encoding? || binary_content?(content)
118 return if @is_binary
119
120 if markdown? && !@plain && @file_size <= MAX_MARKDOWN_BYTES
121 @markdown_html = render_markdown(content)
122 return
123 end
124
125 @line_count = content.lines.count
126 lines = highlighted_lines(content)
127 if lines.length > MAX_DISPLAY_LINES
128 @truncated = true
129 lines = lines.first(MAX_DISPLAY_LINES)
130 end
131 @highlighted_lines = lines
132 end
133
134 def markdown?
135 MARKDOWN_EXTENSIONS.include?(@extension)
136 end
137
138 # Highlighted line fragments, cached by blob SHA (content-addressed, so the
139 # cache is shared across refs and never goes stale). Files past the highlight
140 # budget are escaped as plain text to keep large views snappy.
141 def highlighted_lines(content)
142 if @file_size > MAX_HIGHLIGHT_BYTES
143 @highlight_skipped = true
144 return content.lines.map { |l| ERB::Util.html_escape(l.chomp) }.map(&:html_safe)
145 end
146
147 Rails.cache.fetch([ "blob-hl", @blob_sha ]) do
148 SyntaxHighlighter.lines(content, filename: @filename)
149 end.map(&:html_safe)
150 end
151
152 def render_markdown(content)
153 context = MarkdownRenderer::Context.new(
154 username: @owner.username,
155 repository: @repository.name,
156 ref: @branch,
157 dir: @path_parts[0..-2].join("/")
158 )
159 Rails.cache.fetch([ "md", @blob_sha, context.ref, context.dir ]) do
160 MarkdownRenderer.render(content, context: context)
161 end.html_safe
162 end
163
164 def load_owner
165 @owner = User.find_by!(username: params[:username])
166 rescue ActiveRecord::RecordNotFound
167 render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
168 end
169
170 def load_repository
171 @repository = @owner.repositories.find_by!(name: params[:repository])
172 rescue ActiveRecord::RecordNotFound
173 render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
174 end
175
176 def ensure_can_read!
177 unless !@repository.is_private || (signed_in? && current_user == @owner)
178 render file: Rails.public_path.join("404.html"), status: :not_found, layout: false
179 end
180 end
181
182 def binary_content?(content)
183 content.bytes.first(8192).include?(0)
184 end
185 end