diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/gitlab/google_code_import/importer.rb | 67 | ||||
-rw-r--r-- | lib/gitlab/markdown.rb | 336 | ||||
-rw-r--r-- | lib/gitlab/markdown/commit_range_reference_filter.rb | 105 | ||||
-rw-r--r-- | lib/gitlab/markdown/commit_reference_filter.rb | 80 | ||||
-rw-r--r-- | lib/gitlab/markdown/cross_project_reference.rb | 32 | ||||
-rw-r--r-- | lib/gitlab/markdown/emoji_filter.rb | 79 | ||||
-rw-r--r-- | lib/gitlab/markdown/external_issue_reference_filter.rb | 63 | ||||
-rw-r--r-- | lib/gitlab/markdown/issue_reference_filter.rb | 74 | ||||
-rw-r--r-- | lib/gitlab/markdown/label_reference_filter.rb | 94 | ||||
-rw-r--r-- | lib/gitlab/markdown/merge_request_reference_filter.rb | 73 | ||||
-rw-r--r-- | lib/gitlab/markdown/reference_filter.rb | 76 | ||||
-rw-r--r-- | lib/gitlab/markdown/snippet_reference_filter.rb | 72 | ||||
-rw-r--r-- | lib/gitlab/markdown/user_reference_filter.rb | 92 | ||||
-rw-r--r-- | lib/gitlab/reference_extractor.rb | 70 |
14 files changed, 1001 insertions, 312 deletions
diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb index b5e82563ff1..70bfe059776 100644 --- a/lib/gitlab/google_code_import/importer.rb +++ b/lib/gitlab/google_code_import/importer.rb @@ -30,7 +30,10 @@ module Gitlab def user_map @user_map ||= begin - user_map = Hash.new { |hash, user| Client.mask_email(user) } + user_map = Hash.new do |hash, user| + # Replace ... by \.\.\., so `johnsm...@gmail.com` isn't autolinked. + Client.mask_email(user).sub("...", "\\.\\.\\.") + end import_data = project.import_data.try(:data) stored_user_map = import_data["user_map"] if import_data @@ -203,25 +206,25 @@ module Gitlab end def linkify_issues(s) - s.gsub(/([Ii]ssue) ([0-9]+)/, '\1 #\2') + s = s.gsub(/([Ii]ssue) ([0-9]+)/, '\1 #\2') + s = s.gsub(/([Cc]omment) #([0-9]+)/, '\1 \2') + s end def escape_for_markdown(s) - s = s.gsub("*", "\\*") - s = s.gsub("#", "\\#") + # No headings and lists + s = s.gsub(/^#/, "\\#") + s = s.gsub(/^-/, "\\-") + + # No inline code s = s.gsub("`", "\\`") - s = s.gsub(":", "\\:") - s = s.gsub("-", "\\-") - s = s.gsub("+", "\\+") - s = s.gsub("_", "\\_") - s = s.gsub("(", "\\(") - s = s.gsub(")", "\\)") - s = s.gsub("[", "\\[") - s = s.gsub("]", "\\]") - s = s.gsub("<", "\\<") - s = s.gsub(">", "\\>") + + # Carriage returns make me sad s = s.gsub("\r", "") + + # Markdown ignores single newlines, but we need them as <br />. s = s.gsub("\n", " \n") + s end @@ -276,11 +279,18 @@ module Gitlab if raw_updates.has_key?("blockedOn") blocked_ons = raw_updates["blockedOn"].map do |raw_blocked_on| name, id = raw_blocked_on.split(":", 2) - if name == project.import_source - "##{id}" - else - "#{project.namespace.path}/#{name}##{id}" - end + + deleted = name.start_with?("-") + name = name[1..-1] if deleted + + text = + if name == project.import_source + "##{id}" + else + "#{project.namespace.path}/#{name}##{id}" + end + text = "~~#{text}~~" if deleted + text end updates << "*Blocked on: #{blocked_ons.join(", ")}*" end @@ -288,11 +298,18 @@ module Gitlab if raw_updates.has_key?("blocking") blockings = raw_updates["blocking"].map do |raw_blocked_on| name, id = raw_blocked_on.split(":", 2) - if name == project.import_source - "##{id}" - else - "#{project.namespace.path}/#{name}##{id}" - end + + deleted = name.start_with?("-") + name = name[1..-1] if deleted + + text = + if name == project.import_source + "##{id}" + else + "#{project.namespace.path}/#{name}##{id}" + end + text = "~~#{text}~~" if deleted + text end updates << "*Blocking: #{blockings.join(", ")}*" end @@ -340,7 +357,7 @@ module Gitlab def format_issue_body(author, date, content, attachments) body = [] - body << "*By #{author} on #{date}*" + body << "*By #{author} on #{date} (imported from Google Code)*" body << "---" if content.blank? diff --git a/lib/gitlab/markdown.rb b/lib/gitlab/markdown.rb index 47c456d8dc7..37b250d353e 100644 --- a/lib/gitlab/markdown.rb +++ b/lib/gitlab/markdown.rb @@ -1,5 +1,4 @@ require 'html/pipeline' -require 'html/pipeline/gitlab' module Gitlab # Custom parser for GitLab-flavored Markdown @@ -10,11 +9,11 @@ module Gitlab # Supported reference formats are: # * @foo for team members # * #123 for issues - # * #JIRA-123 for Jira issues + # * JIRA-123 for Jira issues # * !123 for merge requests # * $123 for snippets - # * 123456 for commits - # * 123456...7890123 for commit ranges (comparisons) + # * 1c002d for specific commit + # * 1c002d...35cfb2 for commit ranges (comparisons) # # It also parses Emoji codes to insert images. See # http://www.emoji-cheat-sheet.com/ for a list of the supported icons. @@ -30,10 +29,6 @@ module Gitlab # >> gfm(":trollface:") # => "<img alt=\":trollface:\" class=\"emoji\" src=\"/images/trollface.png" title=\":trollface:\" /> module Markdown - include IssuesHelper - - attr_reader :options, :html_options - # Public: Parse the provided text with GitLab-Flavored Markdown # # text - the source text @@ -65,47 +60,24 @@ module Gitlab reference_only_path: true ) - @options = options - @html_options = html_options - - # TODO: add popups with additional information + pipeline = HTML::Pipeline.new(filters) - # Used markdown pipelines in GitLab: - # GitlabEmojiFilter - performs emoji replacement. - # SanitizationFilter - remove unsafe HTML tags and attributes - # - # see https://gitlab.com/gitlab-org/html-pipeline-gitlab for more filters - filters = [ - HTML::Pipeline::Gitlab::GitlabEmojiFilter, - HTML::Pipeline::SanitizationFilter - ] + context = { + # SanitizationFilter + whitelist: sanitization_whitelist, - whitelist = HTML::Pipeline::SanitizationFilter::WHITELIST - whitelist[:attributes][:all].push('class', 'id') - whitelist[:elements].push('span') - - # Remove the rel attribute that the sanitize gem adds, and remove the - # href attribute if it contains inline javascript - fix_anchors = lambda do |env| - name, node = env[:node_name], env[:node] - if name == 'a' - node.remove_attribute('rel') - if node['href'] && node['href'].match('javascript:') - node.remove_attribute('href') - end - end - end - whitelist[:transformers].push(fix_anchors) + # EmojiFilter + asset_root: Gitlab.config.gitlab.url, + asset_host: Gitlab::Application.config.asset_host, - markdown_context = { - asset_root: Gitlab.config.gitlab.url, - asset_host: Gitlab::Application.config.asset_host, - whitelist: whitelist + # ReferenceFilter + current_user: current_user, + only_path: options[:reference_only_path], + project: project, + reference_class: html_options[:class] } - markdown_pipeline = HTML::Pipeline::Gitlab.new(filters).pipeline - - result = markdown_pipeline.call(text, markdown_context) + result = pipeline.call(text, context) save_options = 0 if options[:xhtml] @@ -114,21 +86,6 @@ module Gitlab text = result[:output].to_html(save_with: save_options) - # Extract pre blocks so they are not altered - # from http://github.github.com/github-flavored-markdown/ - text.gsub!(%r{<pre>.*?</pre>|<code>.*?</code>}m) { |match| extract_piece(match) } - # Extract links with probably parsable hrefs - text.gsub!(%r{<a.*?>.*?</a>}m) { |match| extract_piece(match) } - # Extract images with probably parsable src - text.gsub!(%r{<img.*?>}m) { |match| extract_piece(match) } - - text = parse(text, project) - - # Insert pre block extractions - text.gsub!(/\{gfm-extraction-(\h{32})\}/) do - insert_piece($1) - end - if options[:parse_tasks] text = parse_tasks(text) end @@ -138,242 +95,53 @@ module Gitlab private - def extract_piece(text) - @extractions ||= {} - - md5 = Digest::MD5.hexdigest(text) - @extractions[md5] = text - "{gfm-extraction-#{md5}}" - end - - def insert_piece(id) - @extractions[id] - end - - # Private: Parses text for references + # Filters used in our pipeline # - # text - Text to parse + # SanitizationFilter should come first so that all generated reference HTML + # goes through untouched. # - # Returns parsed text - def parse(text, project = @project) - parse_references(text, project) if project - - text - end - - NAME_STR = Gitlab::Regex::NAMESPACE_REGEX_STR - PROJ_STR = "(?<project>#{NAME_STR}/#{NAME_STR})" - - REFERENCE_PATTERN = %r{ - (?<prefix>\W)? # Prefix - ( # Reference - @(?<user>#{NAME_STR}) # User name - |~(?<label>\d+) # Label ID - |(?<issue>([A-Z\-]+-)\d+) # JIRA Issue ID - |#{PROJ_STR}?\#(?<issue>([a-zA-Z\-]+-)?\d+) # Issue ID - |#{PROJ_STR}?!(?<merge_request>\d+) # MR ID - |\$(?<snippet>\d+) # Snippet ID - |(#{PROJ_STR}@)?(?<commit_range>[\h]{6,40}\.{2,3}[\h]{6,40}) # Commit range - |(#{PROJ_STR}@)?(?<commit>[\h]{6,40}) # Commit ID - |(?<skip>gfm-extraction-[\h]{6,40}) # Skip gfm extractions. Otherwise will be parsed as commit - ) - (?<suffix>\W)? # Suffix - }x.freeze - - TYPES = [:user, :issue, :label, :merge_request, :snippet, :commit, :commit_range].freeze - - def parse_references(text, project = @project) - # parse reference links - text.gsub!(REFERENCE_PATTERN) do |match| - type = TYPES.select{|t| !$~[t].nil?}.first - - actual_project = project - project_prefix = nil - project_path = $LAST_MATCH_INFO[:project] - if project_path - actual_project = ::Project.find_with_namespace(project_path) - actual_project = nil unless can?(current_user, :read_project, actual_project) - project_prefix = project_path - end - - parse_result($LAST_MATCH_INFO, type, - actual_project, project_prefix) || match - end - end - - # Called from #parse_references. Attempts to build a gitlab reference - # link. Returns nil if +type+ is nil, if the match string is an HTML - # entity, if the reference is invalid, or if the matched text includes an - # invalid project path. - def parse_result(match_info, type, project, project_prefix) - prefix = match_info[:prefix] - suffix = match_info[:suffix] - - return nil if html_entity?(prefix, suffix) || type.nil? - return nil if project.nil? && !project_prefix.nil? - - identifier = match_info[type] - ref_link = reference_link(type, identifier, project, project_prefix) - - if ref_link - "#{prefix}#{ref_link}#{suffix}" - else - nil - end - end - - # Return true if the +prefix+ and +suffix+ indicate that the matched string - # is an HTML entity like & - def html_entity?(prefix, suffix) - prefix && suffix && prefix[0] == '&' && suffix[-1] == ';' + # See https://gitlab.com/gitlab-org/html-pipeline-gitlab for more filters + def filters + [ + HTML::Pipeline::SanitizationFilter, + + Gitlab::Markdown::EmojiFilter, + + Gitlab::Markdown::UserReferenceFilter, + Gitlab::Markdown::IssueReferenceFilter, + Gitlab::Markdown::ExternalIssueReferenceFilter, + Gitlab::Markdown::MergeRequestReferenceFilter, + Gitlab::Markdown::SnippetReferenceFilter, + Gitlab::Markdown::CommitRangeReferenceFilter, + Gitlab::Markdown::CommitReferenceFilter, + Gitlab::Markdown::LabelReferenceFilter, + ] end - # Private: Dispatches to a dedicated processing method based on reference - # - # reference - Object reference ("@1234", "!567", etc.) - # identifier - Object identifier (Issue ID, SHA hash, etc.) + # Customize the SanitizationFilter whitelist # - # Returns string rendered by the processing method - def reference_link(type, identifier, project = @project, prefix_text = nil) - send("reference_#{type}", identifier, project, prefix_text) - end - - def reference_user(identifier, project = @project, _ = nil) - link_options = html_options.merge( - class: "gfm gfm-project_member #{html_options[:class]}" - ) + # - Allow `class` and `id` attributes on all elements + # - Allow `span` elements + # - Remove `rel` attributes from `a` elements + # - Remove `a` nodes with `javascript:` in the `href` attribute + def sanitization_whitelist + whitelist = HTML::Pipeline::SanitizationFilter::WHITELIST + whitelist[:attributes][:all].push('class', 'id') + whitelist[:elements].push('span') - if identifier == "all" - link_to( - "@all", - namespace_project_url(project.namespace, project, only_path: options[:reference_only_path]), - link_options - ) - elsif namespace = Namespace.find_by(path: identifier) - url = - if namespace.is_a?(Group) - return nil unless can?(current_user, :read_group, namespace) - group_url(identifier, only_path: options[:reference_only_path]) - else - user_url(identifier, only_path: options[:reference_only_path]) + fix_anchors = lambda do |env| + name, node = env[:node_name], env[:node] + if name == 'a' + node.remove_attribute('rel') + if node['href'] && node['href'].match('javascript:') + node.remove_attribute('href') end - - link_to("@#{identifier}", url, link_options) - end - end - - def reference_label(identifier, project = @project, _ = nil) - if label = project.labels.find_by(id: identifier) - link_options = html_options.merge( - class: "gfm gfm-label #{html_options[:class]}" - ) - link_to( - render_colored_label(label), - namespace_project_issues_path(project.namespace, project, label_name: label.name), - link_options - ) - end - end - - def reference_issue(identifier, project = @project, prefix_text = nil) - if project.default_issues_tracker? - if project.issue_exists? identifier - url = url_for_issue(identifier, project, only_path: options[:reference_only_path]) - title = title_for_issue(identifier, project) - link_options = html_options.merge( - title: "Issue: #{title}", - class: "gfm gfm-issue #{html_options[:class]}" - ) - - link_to("#{prefix_text}##{identifier}", url, link_options) - end - else - if project.external_issue_tracker.present? - reference_external_issue(identifier, project, - prefix_text) end end - end - - def reference_merge_request(identifier, project = @project, prefix_text = nil) - if merge_request = project.merge_requests.find_by(iid: identifier) - link_options = html_options.merge( - title: "Merge Request: #{merge_request.title}", - class: "gfm gfm-merge_request #{html_options[:class]}" - ) - url = namespace_project_merge_request_url(project.namespace, project, - merge_request, - only_path: options[:reference_only_path]) - link_to("#{prefix_text}!#{identifier}", url, link_options) - end - end - - def reference_snippet(identifier, project = @project, _ = nil) - if snippet = project.snippets.find_by(id: identifier) - link_options = html_options.merge( - title: "Snippet: #{snippet.title}", - class: "gfm gfm-snippet #{html_options[:class]}" - ) - link_to( - "$#{identifier}", - namespace_project_snippet_url(project.namespace, project, snippet, - only_path: options[:reference_only_path]), - link_options - ) - end - end - - def reference_commit(identifier, project = @project, prefix_text = nil) - if project.valid_repo? && commit = project.repository.commit(identifier) - link_options = html_options.merge( - title: commit.link_title, - class: "gfm gfm-commit #{html_options[:class]}" - ) - prefix_text = "#{prefix_text}@" if prefix_text - link_to( - "#{prefix_text}#{identifier}", - namespace_project_commit_url( project.namespace, project, commit, - only_path: options[:reference_only_path]), - link_options - ) - end - end - - def reference_commit_range(identifier, project = @project, prefix_text = nil) - from_id, to_id = identifier.split(/\.{2,3}/, 2) - - inclusive = identifier !~ /\.{3}/ - from_id << "^" if inclusive - - if project.valid_repo? && - from = project.repository.commit(from_id) && - to = project.repository.commit(to_id) - - link_options = html_options.merge( - title: "Commits #{from_id} through #{to_id}", - class: "gfm gfm-commit_range #{html_options[:class]}" - ) - prefix_text = "#{prefix_text}@" if prefix_text - link_to( - "#{prefix_text}#{identifier}", - namespace_project_compare_url(project.namespace, project, - from: from_id, to: to_id, - only_path: options[:reference_only_path]), - link_options - ) - end - end - - def reference_external_issue(identifier, project = @project, prefix_text = nil) - url = url_for_issue(identifier, project, only_path: options[:reference_only_path]) - title = project.external_issue_tracker.title + whitelist[:transformers].push(fix_anchors) - link_options = html_options.merge( - title: "Issue in #{title}", - class: "gfm gfm-issue #{html_options[:class]}" - ) - link_to("#{prefix_text}##{identifier}", url, link_options) + whitelist end # Turn list items that start with "[ ]" into HTML checkbox inputs. diff --git a/lib/gitlab/markdown/commit_range_reference_filter.rb b/lib/gitlab/markdown/commit_range_reference_filter.rb new file mode 100644 index 00000000000..1128c1bed7a --- /dev/null +++ b/lib/gitlab/markdown/commit_range_reference_filter.rb @@ -0,0 +1,105 @@ +module Gitlab + module Markdown + # HTML filter that replaces commit range references with links. + # + # This filter supports cross-project references. + class CommitRangeReferenceFilter < ReferenceFilter + include CrossProjectReference + + # Public: Find commit range references in text + # + # CommitRangeReferenceFilter.references_in(text) do |match, commit_range, project_ref| + # "<a href=...>#{commit_range}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, the String commit range, and an optional String + # of the external project reference. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(COMMIT_RANGE_PATTERN) do |match| + yield match, $~[:commit_range], $~[:project] + end + end + + def initialize(*args) + super + + @commit_map = {} + end + + # Pattern used to extract commit range references from text + # + # The beginning and ending SHA1 sums can be between 6 and 40 hex + # characters, and the range selection can be double- or triple-dot. + # + # This pattern supports cross-project references. + COMMIT_RANGE_PATTERN = /(#{PROJECT_PATTERN}@)?(?<commit_range>\h{6,40}\.{2,3}\h{6,40})/ + + def call + replace_text_nodes_matching(COMMIT_RANGE_PATTERN) do |content| + commit_range_link_filter(content) + end + end + + # Replace commit range references in text with links to compare the commit + # ranges. + # + # text - String text to replace references in. + # + # Returns a String with commit range references replaced with links. All + # links have `gfm` and `gfm-commit_range` class names attached for + # styling. + def commit_range_link_filter(text) + self.class.references_in(text) do |match, commit_range, project_ref| + project = self.project_from_ref(project_ref) + + from_id, to_id = split_commit_range(commit_range) + + if valid_range?(project, from_id, to_id) + url = url_for_commit_range(project, from_id, to_id) + + title = "Commits #{from_id} through #{to_id}" + klass = reference_class(:commit_range) + + project_ref += '@' if project_ref + + %(<a href="#{url}" + title="#{title}" + class="#{klass}">#{project_ref}#{commit_range}</a>) + else + match + end + end + end + + def split_commit_range(range) + from_id, to_id = range.split(/\.{2,3}/, 2) + from_id << "^" if range !~ /\.{3}/ + + [from_id, to_id] + end + + def commit(id) + unless @commit_map[id] + @commit_map[id] = project.repository.commit(id) + end + + @commit_map[id] + end + + def valid_range?(project, from_id, to_id) + project && project.valid_repo? && commit(from_id) && commit(to_id) + end + + def url_for_commit_range(project, from_id, to_id) + h = Rails.application.routes.url_helpers + h.namespace_project_compare_url(project.namespace, project, + from: from_id, to: to_id, + only_path: context[:only_path]) + end + end + end +end diff --git a/lib/gitlab/markdown/commit_reference_filter.rb b/lib/gitlab/markdown/commit_reference_filter.rb new file mode 100644 index 00000000000..745de6402cf --- /dev/null +++ b/lib/gitlab/markdown/commit_reference_filter.rb @@ -0,0 +1,80 @@ +module Gitlab + module Markdown + # HTML filter that replaces commit references with links. + # + # This filter supports cross-project references. + class CommitReferenceFilter < ReferenceFilter + include CrossProjectReference + + # Public: Find commit references in text + # + # CommitReferenceFilter.references_in(text) do |match, commit, project_ref| + # "<a href=...>#{commit}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, the String commit identifier, and an optional + # String of the external project reference. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(COMMIT_PATTERN) do |match| + yield match, $~[:commit], $~[:project] + end + end + + # Pattern used to extract commit references from text + # + # The SHA1 sum can be between 6 and 40 hex characters. + # + # This pattern supports cross-project references. + COMMIT_PATTERN = /(#{PROJECT_PATTERN}@)?(?<commit>\h{6,40})/ + + def call + replace_text_nodes_matching(COMMIT_PATTERN) do |content| + commit_link_filter(content) + end + end + + # Replace commit references in text with links to the commit specified. + # + # text - String text to replace references in. + # + # Returns a String with commit references replaced with links. All links + # have `gfm` and `gfm-commit` class names attached for styling. + def commit_link_filter(text) + self.class.references_in(text) do |match, commit_ref, project_ref| + project = self.project_from_ref(project_ref) + + if commit = commit_from_ref(project, commit_ref) + url = url_for_commit(project, commit) + + title = escape_once(commit.link_title) + klass = reference_class(:commit) + + project_ref += '@' if project_ref + + %(<a href="#{url}" + title="#{title}" + class="#{klass}">#{project_ref}#{commit_ref}</a>) + else + match + end + end + end + + def commit_from_ref(project, commit_ref) + if project && project.valid_repo? + project.repository.commit(commit_ref) + end + end + + def url_for_commit(project, commit) + h = Rails.application.routes.url_helpers + h.namespace_project_commit_url(project.namespace, project, commit, + only_path: context[:only_path]) + end + end + end +end diff --git a/lib/gitlab/markdown/cross_project_reference.rb b/lib/gitlab/markdown/cross_project_reference.rb new file mode 100644 index 00000000000..887c205cdc9 --- /dev/null +++ b/lib/gitlab/markdown/cross_project_reference.rb @@ -0,0 +1,32 @@ +module Gitlab + module Markdown + # Common methods for ReferenceFilters that support an optional cross-project + # reference. + module CrossProjectReference + NAMING_PATTERN = Gitlab::Regex::NAMESPACE_REGEX_STR + PROJECT_PATTERN = "(?<project>#{NAMING_PATTERN}/#{NAMING_PATTERN})" + + # Given a cross-project reference string, get the Project record + # + # Defaults to value of `context[:project]` if: + # * No reference is given OR + # * Reference given doesn't exist + # + # ref - String reference. + # + # Returns a Project, or nil if the reference can't be accessed + def project_from_ref(ref) + return context[:project] unless ref + + other = Project.find_with_namespace(ref) + return nil unless other && user_can_reference_project?(other) + + other + end + + def user_can_reference_project?(project, user = context[:current_user]) + Ability.abilities.allowed?(user, :read_project, project) + end + end + end +end diff --git a/lib/gitlab/markdown/emoji_filter.rb b/lib/gitlab/markdown/emoji_filter.rb new file mode 100644 index 00000000000..e239f766844 --- /dev/null +++ b/lib/gitlab/markdown/emoji_filter.rb @@ -0,0 +1,79 @@ +require 'gitlab_emoji' +require 'html/pipeline/filter' +require 'action_controller' + +module Gitlab + module Markdown + # HTML filter that replaces :emoji: with images. + # + # Based on HTML::Pipeline::EmojiFilter + # + # Context options: + # :asset_root + # :asset_host + class EmojiFilter < HTML::Pipeline::Filter + IGNORED_ANCESTOR_TAGS = %w(pre code tt).to_set + + def call + doc.search('text()').each do |node| + content = node.to_html + next unless content.include?(':') + next if has_ancestor?(node, IGNORED_ANCESTOR_TAGS) + + html = emoji_image_filter(content) + + next if html == content + + node.replace(html) + end + + doc + end + + # Replace :emoji: with corresponding images. + # + # text - String text to replace :emoji: in. + # + # Returns a String with :emoji: replaced with images. + def emoji_image_filter(text) + text.gsub(emoji_pattern) do |match| + name = $1 + "<img class='emoji' title=':#{name}:' alt=':#{name}:' src='#{emoji_url(name)}' height='20' width='20' align='absmiddle' />" + end + end + + private + + def emoji_url(name) + emoji_path = "emoji/#{emoji_filename(name)}" + if context[:asset_host] + # Asset host is specified. + url_to_image(emoji_path) + elsif context[:asset_root] + # Gitlab url is specified + File.join(context[:asset_root], url_to_image(emoji_path)) + else + # All other cases + url_to_image(emoji_path) + end + end + + def url_to_image(image) + ActionController::Base.helpers.url_to_image(image) + end + + # Build a regexp that matches all valid :emoji: names. + def self.emoji_pattern + @emoji_pattern ||= /:(#{Emoji.emojis_names.map { |name| Regexp.escape(name) }.join('|')}):/ + end + + def emoji_pattern + self.class.emoji_pattern + end + + def emoji_filename(name) + "#{Emoji.emoji_filename(name)}.png" + end + end + end +end diff --git a/lib/gitlab/markdown/external_issue_reference_filter.rb b/lib/gitlab/markdown/external_issue_reference_filter.rb new file mode 100644 index 00000000000..0fc3f4cca06 --- /dev/null +++ b/lib/gitlab/markdown/external_issue_reference_filter.rb @@ -0,0 +1,63 @@ +module Gitlab + module Markdown + # HTML filter that replaces external issue tracker references with links. + # References are ignored if the project doesn't use an external issue + # tracker. + class ExternalIssueReferenceFilter < ReferenceFilter + # Public: Find `JIRA-123` issue references in text + # + # ExternalIssueReferenceFilter.references_in(text) do |match, issue| + # "<a href=...>##{issue}</a>" + # end + # + # text - String text to search. + # + # Yields the String match and the String issue reference. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(ISSUE_PATTERN) do |match| + yield match, $~[:issue] + end + end + + # Pattern used to extract `JIRA-123` issue references from text + ISSUE_PATTERN = /(?<issue>([A-Z\-]+-)\d+)/ + + def call + # Early return if the project isn't using an external tracker + return doc if project.nil? || project.default_issues_tracker? + + replace_text_nodes_matching(ISSUE_PATTERN) do |content| + issue_link_filter(content) + end + end + + # Replace `JIRA-123` issue references in text with links to the referenced + # issue's details page. + # + # text - String text to replace references in. + # + # Returns a String with `JIRA-123` references replaced with links. All + # links have `gfm` and `gfm-issue` class names attached for styling. + def issue_link_filter(text) + project = context[:project] + + self.class.references_in(text) do |match, issue| + url = url_for_issue(issue, project, only_path: context[:only_path]) + + title = escape_once("Issue in #{project.external_issue_tracker.title}") + klass = reference_class(:issue) + + %(<a href="#{url}" + title="#{title}" + class="#{klass}">#{issue}</a>) + end + end + + def url_for_issue(*args) + IssuesHelper.url_for_issue(*args) + end + end + end +end diff --git a/lib/gitlab/markdown/issue_reference_filter.rb b/lib/gitlab/markdown/issue_reference_filter.rb new file mode 100644 index 00000000000..c9267cc3e9d --- /dev/null +++ b/lib/gitlab/markdown/issue_reference_filter.rb @@ -0,0 +1,74 @@ +module Gitlab + module Markdown + # HTML filter that replaces issue references with links. References to + # issues that do not exist are ignored. + # + # This filter supports cross-project references. + class IssueReferenceFilter < ReferenceFilter + include CrossProjectReference + + # Public: Find `#123` issue references in text + # + # IssueReferenceFilter.references_in(text) do |match, issue, project_ref| + # "<a href=...>##{issue}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, the Integer issue ID, and an optional String of + # the external project reference. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(ISSUE_PATTERN) do |match| + yield match, $~[:issue].to_i, $~[:project] + end + end + + # Pattern used to extract `#123` issue references from text + # + # This pattern supports cross-project references. + ISSUE_PATTERN = /#{PROJECT_PATTERN}?\#(?<issue>([a-zA-Z\-]+-)?\d+)/ + + def call + replace_text_nodes_matching(ISSUE_PATTERN) do |content| + issue_link_filter(content) + end + end + + # Replace `#123` issue references in text with links to the referenced + # issue's details page. + # + # text - String text to replace references in. + # + # Returns a String with `#123` references replaced with links. All links + # have `gfm` and `gfm-issue` class names attached for styling. + def issue_link_filter(text) + self.class.references_in(text) do |match, issue, project_ref| + project = self.project_from_ref(project_ref) + + if project && project.issue_exists?(issue) + url = url_for_issue(issue, project, only_path: context[:only_path]) + + title = escape_once("Issue: #{title_for_issue(issue, project)}") + klass = reference_class(:issue) + + %(<a href="#{url}" + title="#{title}" + class="#{klass}">#{project_ref}##{issue}</a>) + else + match + end + end + end + + def url_for_issue(*args) + IssuesHelper.url_for_issue(*args) + end + + def title_for_issue(*args) + IssuesHelper.title_for_issue(*args) + end + end + end +end diff --git a/lib/gitlab/markdown/label_reference_filter.rb b/lib/gitlab/markdown/label_reference_filter.rb new file mode 100644 index 00000000000..4c21192c0d3 --- /dev/null +++ b/lib/gitlab/markdown/label_reference_filter.rb @@ -0,0 +1,94 @@ +module Gitlab + module Markdown + # HTML filter that replaces label references with links. + class LabelReferenceFilter < ReferenceFilter + # Public: Find label references in text + # + # LabelReferenceFilter.references_in(text) do |match, id, name| + # "<a href=...>#{Label.find(id)}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, an optional Integer label ID, and an optional + # String label name. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(LABEL_PATTERN) do |match| + yield match, $~[:label_id].to_i, $~[:label_name] + end + end + + # Pattern used to extract label references from text + # + # TODO (rspeicher): Limit to double quotes (meh) or disallow single quotes in label names (bad). + LABEL_PATTERN = %r{ + ~( + (?<label_id>\d+) | # Integer-based label ID, or + (?<label_name> + [A-Za-z0-9_-]+ | # String-based single-word label title + ['"][^&\?,]+['"] # String-based multi-word label surrounded in quotes + ) + ) + }x + + def call + replace_text_nodes_matching(LABEL_PATTERN) do |content| + label_link_filter(content) + end + end + + # Replace label references in text with links to the label specified. + # + # text - String text to replace references in. + # + # Returns a String with label references replaced with links. All links + # have `gfm` and `gfm-label` class names attached for styling. + def label_link_filter(text) + project = context[:project] + + self.class.references_in(text) do |match, id, name| + params = label_params(id, name) + + if label = project.labels.find_by(params) + url = url_for_label(project, label) + + klass = reference_class(:label) + + %(<a href="#{url}" class="#{klass}">#{render_colored_label(label)}</a>) + else + match + end + end + end + + def url_for_label(project, label) + h = Rails.application.routes.url_helpers + h.namespace_project_issues_path(project.namespace, project, + label_name: label.name, + only_path: context[:only_path]) + end + + def render_colored_label(label) + LabelsHelper.render_colored_label(label) + end + + # Parameters to pass to `Label.find_by` based on the given arguments + # + # id - Integer ID to pass. If present, returns {id: id} + # name - String name to pass. If `id` is absent, finds by name without + # surrounding quotes. + # + # Returns a Hash. + def label_params(id, name) + if id > 0 + { id: id } + else + # TODO (rspeicher): Don't strip single quotes if we decide to only use double quotes for surrounding. + { name: name.tr('\'"', '') } + end + end + end + end +end diff --git a/lib/gitlab/markdown/merge_request_reference_filter.rb b/lib/gitlab/markdown/merge_request_reference_filter.rb new file mode 100644 index 00000000000..40239523cda --- /dev/null +++ b/lib/gitlab/markdown/merge_request_reference_filter.rb @@ -0,0 +1,73 @@ +module Gitlab + module Markdown + # HTML filter that replaces merge request references with links. References + # to merge requests that do not exist are ignored. + # + # This filter supports cross-project references. + class MergeRequestReferenceFilter < ReferenceFilter + include CrossProjectReference + + # Public: Find `!123` merge request references in text + # + # MergeRequestReferenceFilter.references_in(text) do |match, merge_request, project_ref| + # "<a href=...>##{merge_request}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, the Integer merge request ID, and an optional + # String of the external project reference. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(MERGE_REQUEST_PATTERN) do |match| + yield match, $~[:merge_request].to_i, $~[:project] + end + end + + # Pattern used to extract `!123` merge request references from text + # + # This pattern supports cross-project references. + MERGE_REQUEST_PATTERN = /#{PROJECT_PATTERN}?!(?<merge_request>\d+)/ + + def call + replace_text_nodes_matching(MERGE_REQUEST_PATTERN) do |content| + merge_request_link_filter(content) + end + end + + # Replace `!123` merge request references in text with links to the + # referenced merge request's details page. + # + # text - String text to replace references in. + # + # Returns a String with `!123` references replaced with links. All links + # have `gfm` and `gfm-merge_request` class names attached for styling. + def merge_request_link_filter(text) + self.class.references_in(text) do |match, id, project_ref| + project = self.project_from_ref(project_ref) + + if project && merge_request = project.merge_requests.find_by(iid: id) + title = escape_once("Merge Request: #{merge_request.title}") + klass = reference_class(:merge_request) + + url = url_for_merge_request(merge_request, project) + + %(<a href="#{url}" + title="#{title}" + class="#{klass}">#{project_ref}!#{id}</a>) + else + match + end + end + end + + # TODO (rspeicher): Cleanup + def url_for_merge_request(mr, project) + h = Rails.application.routes.url_helpers + h.namespace_project_merge_request_url(project.namespace, project, mr, + only_path: context[:only_path]) + end + end + end +end diff --git a/lib/gitlab/markdown/reference_filter.rb b/lib/gitlab/markdown/reference_filter.rb new file mode 100644 index 00000000000..26663c8d990 --- /dev/null +++ b/lib/gitlab/markdown/reference_filter.rb @@ -0,0 +1,76 @@ +require 'active_support/core_ext/string/output_safety' +require 'html/pipeline' + +module Gitlab + module Markdown + # Base class for GitLab Flavored Markdown reference filters. + # + # References within <pre>, <code>, <a>, and <style> elements are ignored. + # + # Context options: + # :project (required) - Current project, ignored if reference is cross-project. + # :reference_class - Custom CSS class added to reference links. + # :only_path - Generate path-only links. + # + class ReferenceFilter < HTML::Pipeline::Filter + def escape_once(html) + ERB::Util.html_escape_once(html) + end + + # Don't look for references in text nodes that are children of these + # elements. + IGNORE_PARENTS = %w(pre code a style).to_set + + def ignored_ancestry?(node) + has_ancestor?(node, IGNORE_PARENTS) + end + + def project + context[:project] + end + + def reference_class(type) + "gfm gfm-#{type} #{context[:reference_class]}".strip + end + + # Iterate through the document's text nodes, yielding the current node's + # content if: + # + # * The `project` context value is present AND + # * The node's content matches `pattern` AND + # * The node is not an ancestor of an ignored node type + # + # pattern - Regex pattern against which to match the node's content + # + # Yields the current node's String contents. The result of the block will + # replace the node's existing content and update the current document. + # + # Returns the updated Nokogiri::XML::Document object. + def replace_text_nodes_matching(pattern) + return doc if project.nil? + + doc.search('text()').each do |node| + content = node.to_html + + next unless content.match(pattern) + next if ignored_ancestry?(node) + + html = yield content + + next if html == content + + node.replace(html) + end + + doc + end + + # Ensure that a :project key exists in context + # + # Note that while the key might exist, its value could be nil! + def validate + needs :project + end + end + end +end diff --git a/lib/gitlab/markdown/snippet_reference_filter.rb b/lib/gitlab/markdown/snippet_reference_filter.rb new file mode 100644 index 00000000000..ada67de992b --- /dev/null +++ b/lib/gitlab/markdown/snippet_reference_filter.rb @@ -0,0 +1,72 @@ +module Gitlab + module Markdown + # HTML filter that replaces snippet references with links. References to + # snippets that do not exist are ignored. + # + # This filter supports cross-project references. + class SnippetReferenceFilter < ReferenceFilter + include CrossProjectReference + + # Public: Find `$123` snippet references in text + # + # SnippetReferenceFilter.references_in(text) do |match, snippet| + # "<a href=...>$#{snippet}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, the Integer snippet ID, and an optional String + # of the external project reference. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(SNIPPET_PATTERN) do |match| + yield match, $~[:snippet].to_i, $~[:project] + end + end + + # Pattern used to extract `$123` snippet references from text + # + # This pattern supports cross-project references. + SNIPPET_PATTERN = /#{PROJECT_PATTERN}?\$(?<snippet>\d+)/ + + def call + replace_text_nodes_matching(SNIPPET_PATTERN) do |content| + snippet_link_filter(content) + end + end + + # Replace `$123` snippet references in text with links to the referenced + # snippets's details page. + # + # text - String text to replace references in. + # + # Returns a String with `$123` references replaced with links. All links + # have `gfm` and `gfm-snippet` class names attached for styling. + def snippet_link_filter(text) + self.class.references_in(text) do |match, id, project_ref| + project = self.project_from_ref(project_ref) + + if project && snippet = project.snippets.find_by(id: id) + title = escape_once("Snippet: #{snippet.title}") + klass = reference_class(:snippet) + + url = url_for_snippet(snippet, project) + + %(<a href="#{url}" + title="#{title}" + class="#{klass}">#{project_ref}$#{id}</a>) + else + match + end + end + end + + def url_for_snippet(snippet, project) + h = Rails.application.routes.url_helpers + h.namespace_project_snippet_url(project.namespace, project, snippet, + only_path: context[:only_path]) + end + end + end +end diff --git a/lib/gitlab/markdown/user_reference_filter.rb b/lib/gitlab/markdown/user_reference_filter.rb new file mode 100644 index 00000000000..5fc8ed55fe2 --- /dev/null +++ b/lib/gitlab/markdown/user_reference_filter.rb @@ -0,0 +1,92 @@ +module Gitlab + module Markdown + # HTML filter that replaces user or group references with links. + # + # A special `@all` reference is also supported. + class UserReferenceFilter < ReferenceFilter + # Public: Find `@user` user references in text + # + # UserReferenceFilter.references_in(text) do |match, username| + # "<a href=...>@#{user}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, and the String user name. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(USER_PATTERN) do |match| + yield match, $~[:user] + end + end + + # Pattern used to extract `@user` user references from text + USER_PATTERN = /@(?<user>#{Gitlab::Regex::NAMESPACE_REGEX_STR})/ + + def call + replace_text_nodes_matching(USER_PATTERN) do |content| + user_link_filter(content) + end + end + + # Replace `@user` user references in text with links to the referenced + # user's profile page. + # + # text - String text to replace references in. + # + # Returns a String with `@user` references replaced with links. All links + # have `gfm` and `gfm-project_member` class names attached for styling. + def user_link_filter(text) + project = context[:project] + + self.class.references_in(text) do |match, user| + klass = reference_class(:project_member) + + if user == 'all' + url = link_to_all(project) + + %(<a href="#{url}" class="#{klass}">@#{user}</a>) + elsif namespace = Namespace.find_by(path: user) + if namespace.is_a?(Group) + if user_can_reference_group?(namespace) + url = group_url(user, only_path: context[:only_path]) + %(<a href="#{url}" class="#{klass}">@#{user}</a>) + else + match + end + else + url = user_url(user, only_path: context[:only_path]) + %(<a href="#{url}" class="#{klass}">@#{user}</a>) + end + else + match + end + end + end + + private + + def urls + Rails.application.routes.url_helpers + end + + def group_url(*args) + urls.group_url(*args) + end + + def user_url(*args) + urls.user_url(*args) + end + + def link_to_all(project) + urls.namespace_project_url(project.namespace, project, + only_path: context[:only_path]) + end + + def user_can_reference_group?(group) + Ability.abilities.allowed?(context[:current_user], :read_group, group) + end + end + end +end diff --git a/lib/gitlab/reference_extractor.rb b/lib/gitlab/reference_extractor.rb index a502a8fe9cd..34aae384355 100644 --- a/lib/gitlab/reference_extractor.rb +++ b/lib/gitlab/reference_extractor.rb @@ -3,8 +3,6 @@ module Gitlab class ReferenceExtractor attr_accessor :project, :current_user, :references - include ::Gitlab::Markdown - def initialize(project, current_user = nil) @project = project @current_user = current_user @@ -34,7 +32,7 @@ module Gitlab project.team.members.flatten elsif namespace = Namespace.find_by(path: identifier) if namespace.is_a?(Group) - namespace.users + namespace.users if can?(current_user, :read_group, namespace) else namespace.owner end @@ -87,6 +85,72 @@ module Gitlab private + NAME_STR = Gitlab::Regex::NAMESPACE_REGEX_STR + PROJ_STR = "(?<project>#{NAME_STR}/#{NAME_STR})" + + REFERENCE_PATTERN = %r{ + (?<prefix>\W)? # Prefix + ( # Reference + @(?<user>#{NAME_STR}) # User name + |~(?<label>\d+) # Label ID + |(?<issue>([A-Z\-]+-)\d+) # JIRA Issue ID + |#{PROJ_STR}?\#(?<issue>([a-zA-Z\-]+-)?\d+) # Issue ID + |#{PROJ_STR}?!(?<merge_request>\d+) # MR ID + |\$(?<snippet>\d+) # Snippet ID + |(#{PROJ_STR}@)?(?<commit_range>[\h]{6,40}\.{2,3}[\h]{6,40}) # Commit range + |(#{PROJ_STR}@)?(?<commit>[\h]{6,40}) # Commit ID + ) + (?<suffix>\W)? # Suffix + }x.freeze + + TYPES = %i(user issue label merge_request snippet commit commit_range).freeze + + def parse_references(text, project = @project) + # parse reference links + text.gsub!(REFERENCE_PATTERN) do |match| + type = TYPES.detect { |t| $~[t].present? } + + actual_project = project + project_prefix = nil + project_path = $LAST_MATCH_INFO[:project] + if project_path + actual_project = ::Project.find_with_namespace(project_path) + actual_project = nil unless can?(current_user, :read_project, actual_project) + project_prefix = project_path + end + + parse_result($LAST_MATCH_INFO, type, + actual_project, project_prefix) || match + end + end + + # Called from #parse_references. Attempts to build a gitlab reference + # link. Returns nil if +type+ is nil, if the match string is an HTML + # entity, if the reference is invalid, or if the matched text includes an + # invalid project path. + def parse_result(match_info, type, project, project_prefix) + prefix = match_info[:prefix] + suffix = match_info[:suffix] + + return nil if html_entity?(prefix, suffix) || type.nil? + return nil if project.nil? && !project_prefix.nil? + + identifier = match_info[type] + ref_link = reference_link(type, identifier, project, project_prefix) + + if ref_link + "#{prefix}#{ref_link}#{suffix}" + else + nil + end + end + + # Return true if the +prefix+ and +suffix+ indicate that the matched string + # is an HTML entity like & + def html_entity?(prefix, suffix) + prefix && suffix && prefix[0] == '&' && suffix[-1] == ';' + end + def reference_link(type, identifier, project, _) references[type] << [project, identifier] end |