| 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 |