diff options
Diffstat (limited to 'lib/banzai')
51 files changed, 1986 insertions, 1945 deletions
diff --git a/lib/banzai/filter/abstract_reference_filter.rb b/lib/banzai/filter/abstract_reference_filter.rb deleted file mode 100644 index 2448c2c2bb2..00000000000 --- a/lib/banzai/filter/abstract_reference_filter.rb +++ /dev/null @@ -1,446 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # Issues, Merge Requests, Snippets, Commits and Commit Ranges share - # similar functionality in reference filtering. - class AbstractReferenceFilter < ReferenceFilter - include CrossProjectReference - - # REFERENCE_PLACEHOLDER is used for re-escaping HTML text except found - # reference (which we replace with placeholder during re-scaping). The - # random number helps ensure it's pretty close to unique. Since it's a - # transitory value (it never gets saved) we can initialize once, and it - # doesn't matter if it changes on a restart. - REFERENCE_PLACEHOLDER = "_reference_#{SecureRandom.hex(16)}_" - REFERENCE_PLACEHOLDER_PATTERN = %r{#{REFERENCE_PLACEHOLDER}(\d+)}.freeze - - def self.object_class - # Implement in child class - # Example: MergeRequest - end - - def self.object_name - @object_name ||= object_class.name.underscore - end - - def self.object_sym - @object_sym ||= object_name.to_sym - end - - # Public: Find references in text (like `!123` for merge requests) - # - # AnyReferenceFilter.references_in(text) do |match, id, project_ref, matches| - # object = find_object(project_ref, id) - # "<a href=...>#{object.to_reference}</a>" - # end - # - # text - String text to search. - # - # Yields the String match, the Integer referenced object ID, an optional String - # of the external project reference, and all of the matchdata. - # - # Returns a String replaced with the return of the block. - def self.references_in(text, pattern = object_class.reference_pattern) - text.gsub(pattern) do |match| - if ident = identifier($~) - yield match, ident, $~[:project], $~[:namespace], $~ - else - match - end - end - end - - def self.identifier(match_data) - symbol = symbol_from_match(match_data) - - parse_symbol(symbol, match_data) if object_class.reference_valid?(symbol) - end - - def identifier(match_data) - self.class.identifier(match_data) - end - - def self.symbol_from_match(match) - key = object_sym - match[key] if match.names.include?(key.to_s) - end - - # Transform a symbol extracted from the text to a meaningful value - # In most cases these will be integers, so we call #to_i by default - # - # This method has the contract that if a string `ref` refers to a - # record `record`, then `parse_symbol(ref) == record_identifier(record)`. - def self.parse_symbol(symbol, match_data) - symbol.to_i - end - - # We assume that most classes are identifying records by ID. - # - # This method has the contract that if a string `ref` refers to a - # record `record`, then `class.parse_symbol(ref) == record_identifier(record)`. - def record_identifier(record) - record.id - end - - def object_class - self.class.object_class - end - - def object_sym - self.class.object_sym - end - - def references_in(*args, &block) - self.class.references_in(*args, &block) - end - - # Implement in child class - # Example: project.merge_requests.find - def find_object(parent_object, id) - end - - # Override if the link reference pattern produces a different ID (global - # ID vs internal ID, for instance) to the regular reference pattern. - def find_object_from_link(parent_object, id) - find_object(parent_object, id) - end - - # Implement in child class - # Example: project_merge_request_url - def url_for_object(object, parent_object) - end - - def find_object_cached(parent_object, id) - cached_call(:banzai_find_object, id, path: [object_class, parent_object.id]) do - find_object(parent_object, id) - end - end - - def find_object_from_link_cached(parent_object, id) - cached_call(:banzai_find_object_from_link, id, path: [object_class, parent_object.id]) do - find_object_from_link(parent_object, id) - end - end - - def from_ref_cached(ref) - cached_call("banzai_#{parent_type}_refs".to_sym, ref) do - parent_from_ref(ref) - end - end - - def url_for_object_cached(object, parent_object) - cached_call(:banzai_url_for_object, object, path: [object_class, parent_object.id]) do - url_for_object(object, parent_object) - end - end - - def call - return doc unless project || group || user - - ref_pattern = object_class.reference_pattern - link_pattern = object_class.link_reference_pattern - - # Compile often used regexps only once outside of the loop - ref_pattern_anchor = /\A#{ref_pattern}\z/ - link_pattern_start = /\A#{link_pattern}/ - link_pattern_anchor = /\A#{link_pattern}\z/ - - nodes.each_with_index do |node, index| - if text_node?(node) && ref_pattern - replace_text_when_pattern_matches(node, index, ref_pattern) do |content| - object_link_filter(content, ref_pattern) - end - - elsif element_node?(node) - yield_valid_link(node) do |link, inner_html| - if ref_pattern && link =~ ref_pattern_anchor - replace_link_node_with_href(node, index, link) do - object_link_filter(link, ref_pattern, link_content: inner_html) - end - - next - end - - next unless link_pattern - - if link == inner_html && inner_html =~ link_pattern_start - replace_link_node_with_text(node, index) do - object_link_filter(inner_html, link_pattern, link_reference: true) - end - - next - end - - if link =~ link_pattern_anchor - replace_link_node_with_href(node, index, link) do - object_link_filter(link, link_pattern, link_content: inner_html, link_reference: true) - end - - next - end - end - end - end - - doc - end - - # Replace references (like `!123` for merge requests) in text with links - # to the referenced object's details page. - # - # text - String text to replace references in. - # pattern - Reference pattern to match against. - # link_content - Original content of the link being replaced. - # link_reference - True if this was using the link reference pattern, - # false otherwise. - # - # Returns a String with references replaced with links. All links - # have `gfm` and `gfm-OBJECT_NAME` class names attached for styling. - def object_link_filter(text, pattern, link_content: nil, link_reference: false) - references_in(text, pattern) do |match, id, project_ref, namespace_ref, matches| - parent_path = if parent_type == :group - full_group_path(namespace_ref) - else - full_project_path(namespace_ref, project_ref) - end - - parent = from_ref_cached(parent_path) - - if parent - object = - if link_reference - find_object_from_link_cached(parent, id) - else - find_object_cached(parent, id) - end - end - - if object - title = object_link_title(object, matches) - klass = reference_class(object_sym) - - data_attributes = data_attributes_for(link_content || match, parent, object, - link_content: !!link_content, - link_reference: link_reference) - data = data_attribute(data_attributes) - - url = - if matches.names.include?("url") && matches[:url] - matches[:url] - else - url_for_object_cached(object, parent) - end - - content = link_content || object_link_text(object, matches) - - link = %(<a href="#{url}" #{data} - title="#{escape_once(title)}" - class="#{klass}">#{content}</a>) - - wrap_link(link, object) - else - match - end - end - end - - def wrap_link(link, object) - link - end - - def data_attributes_for(text, parent, object, link_content: false, link_reference: false) - object_parent_type = parent.is_a?(Group) ? :group : :project - - { - original: escape_html_entities(text), - link: link_content, - link_reference: link_reference, - object_parent_type => parent.id, - object_sym => object.id - } - end - - def object_link_text_extras(object, matches) - extras = [] - - if matches.names.include?("anchor") && matches[:anchor] && matches[:anchor] =~ /\A\#note_(\d+)\z/ - extras << "comment #{Regexp.last_match(1)}" - end - - extension = matches[:extension] if matches.names.include?("extension") - - extras << extension if extension - - extras - end - - def object_link_title(object, matches) - object.title - end - - def object_link_text(object, matches) - parent = project || group || user - text = object.reference_link_text(parent) - - extras = object_link_text_extras(object, matches) - text += " (#{extras.join(", ")})" if extras.any? - - text - end - - # Returns a Hash containing all object references (e.g. issue IDs) per the - # project they belong to. - def references_per_parent - @references_per ||= {} - - @references_per[parent_type] ||= begin - refs = Hash.new { |hash, key| hash[key] = Set.new } - regex = [ - object_class.link_reference_pattern, - object_class.reference_pattern - ].compact.reduce { |a, b| Regexp.union(a, b) } - - nodes.each do |node| - node.to_html.scan(regex) do - path = if parent_type == :project - full_project_path($~[:namespace], $~[:project]) - else - full_group_path($~[:group]) - end - - if ident = identifier($~) - refs[path] << ident - end - end - end - - refs - end - end - - # Returns a Hash containing referenced projects grouped per their full - # path. - def parent_per_reference - @per_reference ||= {} - - @per_reference[parent_type] ||= begin - refs = Set.new - - references_per_parent.each do |ref, _| - refs << ref - end - - find_for_paths(refs.to_a).index_by(&:full_path) - end - end - - def relation_for_paths(paths) - klass = parent_type.to_s.camelize.constantize - result = klass.where_full_path_in(paths) - return result if parent_type == :group - - result.includes(:namespace) if parent_type == :project - end - - # Returns projects for the given paths. - def find_for_paths(paths) - if Gitlab::SafeRequestStore.active? - cache = refs_cache - to_query = paths - cache.keys - - unless to_query.empty? - records = relation_for_paths(to_query) - - found = [] - records.each do |record| - ref = record.full_path - get_or_set_cache(cache, ref) { record } - found << ref - end - - not_found = to_query - found - not_found.each do |ref| - get_or_set_cache(cache, ref) { nil } - end - end - - cache.slice(*paths).values.compact - else - relation_for_paths(paths) - end - end - - def current_parent_path - @current_parent_path ||= parent&.full_path - end - - def current_project_namespace_path - @current_project_namespace_path ||= project&.namespace&.full_path - end - - def records_per_parent - @_records_per_project ||= {} - - @_records_per_project[object_class.to_s.underscore] ||= begin - hash = Hash.new { |h, k| h[k] = {} } - - parent_per_reference.each do |path, parent| - record_ids = references_per_parent[path] - - parent_records(parent, record_ids).each do |record| - hash[parent][record_identifier(record)] = record - end - end - - hash - end - end - - private - - def full_project_path(namespace, project_ref) - return current_parent_path unless project_ref - - namespace_ref = namespace || current_project_namespace_path - "#{namespace_ref}/#{project_ref}" - end - - def refs_cache - Gitlab::SafeRequestStore["banzai_#{parent_type}_refs".to_sym] ||= {} - end - - def parent_type - :project - end - - def parent - parent_type == :project ? project : group - end - - def full_group_path(group_ref) - return current_parent_path unless group_ref - - group_ref - end - - def unescape_html_entities(text) - CGI.unescapeHTML(text.to_s) - end - - def escape_html_entities(text) - CGI.escapeHTML(text.to_s) - end - - def escape_with_placeholders(text, placeholder_data) - escaped = escape_html_entities(text) - - escaped.gsub(REFERENCE_PLACEHOLDER_PATTERN) do |match| - placeholder_data[Regexp.last_match(1).to_i] - end - end - end - end -end - -Banzai::Filter::AbstractReferenceFilter.prepend_if_ee('EE::Banzai::Filter::AbstractReferenceFilter') diff --git a/lib/banzai/filter/alert_reference_filter.rb b/lib/banzai/filter/alert_reference_filter.rb deleted file mode 100644 index 228a4159c99..00000000000 --- a/lib/banzai/filter/alert_reference_filter.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - class AlertReferenceFilter < IssuableReferenceFilter - self.reference_type = :alert - - def self.object_class - AlertManagement::Alert - end - - def self.object_sym - :alert - end - - def parent_records(parent, ids) - parent.alert_management_alerts.where(iid: ids.to_a) - end - - def url_for_object(alert, project) - ::Gitlab::Routing.url_helpers.details_project_alert_management_url( - project, - alert.iid, - only_path: context[:only_path] - ) - end - end - end -end diff --git a/lib/banzai/filter/autolink_filter.rb b/lib/banzai/filter/autolink_filter.rb index d569711431c..a86c1bb2892 100644 --- a/lib/banzai/filter/autolink_filter.rb +++ b/lib/banzai/filter/autolink_filter.rb @@ -43,7 +43,7 @@ module Banzai TEXT_QUERY = %Q(descendant-or-self::text()[ not(#{IGNORE_PARENTS.map { |p| "ancestor::#{p}" }.join(' or ')}) and contains(., '://') - ]).freeze + ]) PUNCTUATION_PAIRS = { "'" => "'", diff --git a/lib/banzai/filter/commit_range_reference_filter.rb b/lib/banzai/filter/commit_range_reference_filter.rb deleted file mode 100644 index d6b46236a49..00000000000 --- a/lib/banzai/filter/commit_range_reference_filter.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # HTML filter that replaces commit range references with links. - # - # This filter supports cross-project references. - class CommitRangeReferenceFilter < AbstractReferenceFilter - self.reference_type = :commit_range - - def self.object_class - CommitRange - end - - def self.references_in(text, pattern = CommitRange.reference_pattern) - text.gsub(pattern) do |match| - yield match, $~[:commit_range], $~[:project], $~[:namespace], $~ - end - end - - def initialize(*args) - super - - @commit_map = {} - end - - def find_object(project, id) - return unless project.is_a?(Project) - - range = CommitRange.new(id, project) - - range.valid_commits? ? range : nil - end - - def url_for_object(range, project) - h = Gitlab::Routing.url_helpers - h.project_compare_url(project, - range.to_param.merge(only_path: context[:only_path])) - end - - def object_link_title(range, matches) - nil - end - end - end -end diff --git a/lib/banzai/filter/commit_reference_filter.rb b/lib/banzai/filter/commit_reference_filter.rb deleted file mode 100644 index 3df003a88fa..00000000000 --- a/lib/banzai/filter/commit_reference_filter.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # HTML filter that replaces commit references with links. - # - # This filter supports cross-project references. - class CommitReferenceFilter < AbstractReferenceFilter - self.reference_type = :commit - - def self.object_class - Commit - end - - def self.references_in(text, pattern = Commit.reference_pattern) - text.gsub(pattern) do |match| - yield match, $~[:commit], $~[:project], $~[:namespace], $~ - end - end - - def find_object(project, id) - return unless project.is_a?(Project) && project.valid_repo? - - _, record = records_per_parent[project].detect { |k, _v| Gitlab::Git.shas_eql?(k, id) } - - record - end - - def referenced_merge_request_commit_shas - return [] unless noteable.is_a?(MergeRequest) - - @referenced_merge_request_commit_shas ||= begin - referenced_shas = references_per_parent.values.reduce(:|).to_a - noteable.all_commit_shas.select do |sha| - referenced_shas.any? { |ref| Gitlab::Git.shas_eql?(sha, ref) } - end - end - end - - # The default behaviour is `#to_i` - we just pass the hash through. - def self.parse_symbol(sha_hash, _match) - sha_hash - end - - def url_for_object(commit, project) - h = Gitlab::Routing.url_helpers - - if referenced_merge_request_commit_shas.include?(commit.id) - h.diffs_project_merge_request_url(project, - noteable, - commit_id: commit.id, - only_path: only_path?) - else - h.project_commit_url(project, - commit, - only_path: only_path?) - end - end - - def object_link_text_extras(object, matches) - extras = super - - path = matches[:path] if matches.names.include?("path") - if path == '/builds' - extras.unshift "builds" - end - - extras - end - - private - - def parent_records(parent, ids) - parent.commits_by(oids: ids.to_a) - end - - def noteable - context[:noteable] - end - - def only_path? - context[:only_path] - end - end - end -end diff --git a/lib/banzai/filter/commit_trailers_filter.rb b/lib/banzai/filter/commit_trailers_filter.rb index 5288db3b0cb..a615abc1989 100644 --- a/lib/banzai/filter/commit_trailers_filter.rb +++ b/lib/banzai/filter/commit_trailers_filter.rb @@ -36,7 +36,7 @@ module Banzai next if html == content - node.replace(html) + node.replace("\n\n#{html}") end doc diff --git a/lib/banzai/filter/design_reference_filter.rb b/lib/banzai/filter/design_reference_filter.rb deleted file mode 100644 index 1754fec93d4..00000000000 --- a/lib/banzai/filter/design_reference_filter.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - class DesignReferenceFilter < AbstractReferenceFilter - class Identifier - include Comparable - attr_reader :issue_iid, :filename - - def initialize(issue_iid:, filename:) - @issue_iid = issue_iid - @filename = filename - end - - def as_composite_id(id_for_iid) - id = id_for_iid[issue_iid] - return unless id - - { issue_id: id, filename: filename } - end - - def <=>(other) - return unless other.is_a?(Identifier) - - [issue_iid, filename] <=> [other.issue_iid, other.filename] - end - alias_method :eql?, :== - - def hash - [issue_iid, filename].hash - end - end - - self.reference_type = :design - - def find_object(project, identifier) - records_per_parent[project][identifier] - end - - def parent_records(project, identifiers) - return [] unless project.design_management_enabled? - - iids = identifiers.map(&:issue_iid).to_set - issues = project.issues.where(iid: iids) - id_for_iid = issues.index_by(&:iid).transform_values(&:id) - issue_by_id = issues.index_by(&:id) - - designs(identifiers, id_for_iid).each do |d| - issue = issue_by_id[d.issue_id] - # optimisation: assign values we have already fetched - d.project = project - d.issue = issue - end - end - - def relation_for_paths(paths) - super.includes(:route, :namespace, :group) - end - - def parent_type - :project - end - - # optimisation to reuse the parent_per_reference query information - def parent_from_ref(ref) - parent_per_reference[ref || current_parent_path] - end - - def url_for_object(design, project) - path_options = { vueroute: design.filename } - Gitlab::Routing.url_helpers.designs_project_issue_path(project, design.issue, path_options) - end - - def data_attributes_for(_text, _project, design, **_kwargs) - super.merge(issue: design.issue_id) - end - - def self.object_class - ::DesignManagement::Design - end - - def self.object_sym - :design - end - - def self.parse_symbol(raw, match_data) - filename = match_data[:url_filename] - iid = match_data[:issue].to_i - Identifier.new(filename: CGI.unescape(filename), issue_iid: iid) - end - - def record_identifier(design) - Identifier.new(filename: design.filename, issue_iid: design.issue.iid) - end - - private - - def designs(identifiers, id_for_iid) - identifiers - .map { |identifier| identifier.as_composite_id(id_for_iid) } - .compact - .in_groups_of(100, false) # limitation of by_issue_id_and_filename, so we batch - .flat_map { |ids| DesignManagement::Design.by_issue_id_and_filename(ids) } - end - end - end -end diff --git a/lib/banzai/filter/epic_reference_filter.rb b/lib/banzai/filter/epic_reference_filter.rb deleted file mode 100644 index 70a6cb0a6dc..00000000000 --- a/lib/banzai/filter/epic_reference_filter.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # The actual filter is implemented in the EE mixin - class EpicReferenceFilter < IssuableReferenceFilter - self.reference_type = :epic - - def self.object_class - Epic - end - - private - - def group - context[:group] || context[:project]&.group - end - end - end -end - -Banzai::Filter::EpicReferenceFilter.prepend_if_ee('EE::Banzai::Filter::EpicReferenceFilter') diff --git a/lib/banzai/filter/external_issue_reference_filter.rb b/lib/banzai/filter/external_issue_reference_filter.rb deleted file mode 100644 index fcf4863ab4f..00000000000 --- a/lib/banzai/filter/external_issue_reference_filter.rb +++ /dev/null @@ -1,118 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # HTML filter that replaces external issue tracker references with links. - # References are ignored if the project doesn't use an external issue - # tracker. - # - # This filter does not support cross-project references. - class ExternalIssueReferenceFilter < ReferenceFilter - self.reference_type = :external_issue - - # Public: Find `JIRA-123` issue references in text - # - # ExternalIssueReferenceFilter.references_in(text, pattern) 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, pattern) - text.gsub(pattern) do |match| - yield match, $~[:issue] - end - end - - def call - # Early return if the project isn't using an external tracker - return doc if project.nil? || default_issues_tracker? - - ref_pattern = issue_reference_pattern - ref_start_pattern = /\A#{ref_pattern}\z/ - - nodes.each_with_index do |node, index| - if text_node?(node) - replace_text_when_pattern_matches(node, index, ref_pattern) do |content| - issue_link_filter(content) - end - - elsif element_node?(node) - yield_valid_link(node) do |link, inner_html| - if link =~ ref_start_pattern - replace_link_node_with_href(node, index, link) do - issue_link_filter(link, link_content: inner_html) - end - end - end - end - end - - doc - end - - private - - # Replace `JIRA-123` issue references in text with links to the referenced - # issue's details page. - # - # text - String text to replace references in. - # link_content - Original content of the link being replaced. - # - # 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, link_content: nil) - self.class.references_in(text, issue_reference_pattern) do |match, id| - url = url_for_issue(id) - klass = reference_class(:issue) - data = data_attribute(project: project.id, external_issue: id) - content = link_content || match - - %(<a href="#{url}" #{data} - title="#{escape_once(issue_title)}" - class="#{klass}">#{content}</a>) - end - end - - def url_for_issue(issue_id) - return '' if project.nil? - - url = if only_path? - project.external_issue_tracker.issue_path(issue_id) - else - project.external_issue_tracker.issue_url(issue_id) - end - - # Ensure we return a valid URL to prevent possible XSS. - URI.parse(url).to_s - rescue URI::InvalidURIError - '' - end - - def default_issues_tracker? - external_issues_cached(:default_issues_tracker?) - end - - def issue_reference_pattern - external_issues_cached(:external_issue_reference_pattern) - end - - def project - context[:project] - end - - def issue_title - "Issue in #{project.external_issue_tracker.title}" - end - - def external_issues_cached(attribute) - cached_attributes = Gitlab::SafeRequestStore[:banzai_external_issues_tracker_attributes] ||= Hash.new { |h, k| h[k] = {} } - cached_attributes[project.id][attribute] = project.public_send(attribute) if cached_attributes[project.id][attribute].nil? # rubocop:disable GitlabSecurity/PublicSend - cached_attributes[project.id][attribute] - end - end - end -end diff --git a/lib/banzai/filter/feature_flag_reference_filter.rb b/lib/banzai/filter/feature_flag_reference_filter.rb deleted file mode 100644 index c11576901ce..00000000000 --- a/lib/banzai/filter/feature_flag_reference_filter.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - class FeatureFlagReferenceFilter < IssuableReferenceFilter - self.reference_type = :feature_flag - - def self.object_class - Operations::FeatureFlag - end - - def self.object_sym - :feature_flag - end - - def parent_records(parent, ids) - parent.operations_feature_flags.where(iid: ids.to_a) - end - - def url_for_object(feature_flag, project) - ::Gitlab::Routing.url_helpers.edit_project_feature_flag_url( - project, - feature_flag.iid, - only_path: context[:only_path] - ) - end - - def object_link_title(object, matches) - object.name - end - end - end -end diff --git a/lib/banzai/filter/gollum_tags_filter.rb b/lib/banzai/filter/gollum_tags_filter.rb index 8a7d3c49ffb..6de9f2b86f6 100644 --- a/lib/banzai/filter/gollum_tags_filter.rb +++ b/lib/banzai/filter/gollum_tags_filter.rb @@ -98,14 +98,15 @@ module Banzai return unless image?(content) - if url?(content) - path = content - elsif file = wiki.find_file(content, load_content: false) - path = ::File.join(wiki_base_path, file.path) - end + path = + if url?(content) + content + elsif file = wiki.find_file(content, load_content: false) + file.path + end if path - content_tag(:img, nil, data: { src: path }, class: 'gfm') + content_tag(:img, nil, src: path, class: 'gfm') end end diff --git a/lib/banzai/filter/issuable_reference_filter.rb b/lib/banzai/filter/issuable_reference_filter.rb deleted file mode 100644 index b91ba9f7256..00000000000 --- a/lib/banzai/filter/issuable_reference_filter.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - class IssuableReferenceFilter < AbstractReferenceFilter - def record_identifier(record) - record.iid.to_i - end - - def find_object(parent, iid) - records_per_parent[parent][iid] - end - - def parent_from_ref(ref) - parent_per_reference[ref || current_parent_path] - end - end - end -end diff --git a/lib/banzai/filter/issue_reference_filter.rb b/lib/banzai/filter/issue_reference_filter.rb deleted file mode 100644 index 216418ee5fa..00000000000 --- a/lib/banzai/filter/issue_reference_filter.rb +++ /dev/null @@ -1,57 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # HTML filter that replaces issue references with links. References to - # issues that do not exist are ignored. - # - # This filter supports cross-project references. - # - # When external issues tracker like Jira is activated we should not - # use issue reference pattern, but we should still be able - # to reference issues from other GitLab projects. - class IssueReferenceFilter < IssuableReferenceFilter - self.reference_type = :issue - - def self.object_class - Issue - end - - def url_for_object(issue, project) - return issue_path(issue, project) if only_path? - - issue_url(issue, project) - end - - def parent_records(parent, ids) - parent.issues.where(iid: ids.to_a) - end - - def object_link_text_extras(issue, matches) - super + design_link_extras(issue, matches.named_captures['path']) - end - - private - - def issue_path(issue, project) - Gitlab::Routing.url_helpers.namespace_project_issue_path(namespace_id: project.namespace, project_id: project, id: issue.iid) - end - - def issue_url(issue, project) - Gitlab::Routing.url_helpers.namespace_project_issue_url(namespace_id: project.namespace, project_id: project, id: issue.iid) - end - - def design_link_extras(issue, path) - if path == '/designs' && read_designs?(issue) - ['designs'] - else - [] - end - end - - def read_designs?(issue) - issue.project.design_management_enabled? - end - end - end -end diff --git a/lib/banzai/filter/iteration_reference_filter.rb b/lib/banzai/filter/iteration_reference_filter.rb deleted file mode 100644 index 9d2b533e6da..00000000000 --- a/lib/banzai/filter/iteration_reference_filter.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # The actual filter is implemented in the EE mixin - class IterationReferenceFilter < AbstractReferenceFilter - self.reference_type = :iteration - - def self.object_class - Iteration - end - end - end -end - -Banzai::Filter::IterationReferenceFilter.prepend_if_ee('EE::Banzai::Filter::IterationReferenceFilter') diff --git a/lib/banzai/filter/label_reference_filter.rb b/lib/banzai/filter/label_reference_filter.rb deleted file mode 100644 index a4d3e352051..00000000000 --- a/lib/banzai/filter/label_reference_filter.rb +++ /dev/null @@ -1,129 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # HTML filter that replaces label references with links. - class LabelReferenceFilter < AbstractReferenceFilter - self.reference_type = :label - - def self.object_class - Label - end - - def find_object(parent_object, id) - find_labels(parent_object).find(id) - end - - def references_in(text, pattern = Label.reference_pattern) - labels = {} - unescaped_html = unescape_html_entities(text).gsub(pattern) do |match| - namespace, project = $~[:namespace], $~[:project] - project_path = full_project_path(namespace, project) - label = find_label_cached(project_path, $~[:label_id], $~[:label_name]) - - if label - labels[label.id] = yield match, label.id, project, namespace, $~ - "#{REFERENCE_PLACEHOLDER}#{label.id}" - else - match - end - end - - return text if labels.empty? - - escape_with_placeholders(unescaped_html, labels) - end - - def find_label_cached(parent_ref, label_id, label_name) - cached_call(:banzai_find_label_cached, label_name&.tr('"', '') || label_id, path: [object_class, parent_ref]) do - find_label(parent_ref, label_id, label_name) - end - end - - def find_label(parent_ref, label_id, label_name) - parent = parent_from_ref(parent_ref) - return unless parent - - label_params = label_params(label_id, label_name) - find_labels(parent).find_by(label_params) - end - - def find_labels(parent) - params = if parent.is_a?(Group) - { group_id: parent.id, - include_ancestor_groups: true, - only_group_labels: true } - else - { project: parent, - include_ancestor_groups: true } - end - - LabelsFinder.new(nil, params).execute(skip_authorization: true) - 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 name - { name: name.tr('"', '') } - else - { id: id.to_i } - end - end - - def url_for_object(label, parent) - label_url_method = - if context[:label_url_method] - context[:label_url_method] - elsif parent.is_a?(Project) - :project_issues_url - end - - return unless label_url_method - - Gitlab::Routing.url_helpers.public_send(label_url_method, parent, label_name: label.name, only_path: context[:only_path]) # rubocop:disable GitlabSecurity/PublicSend - end - - def object_link_text(object, matches) - label_suffix = '' - parent = project || group - - if project || full_path_ref?(matches) - project_path = full_project_path(matches[:namespace], matches[:project]) - parent_from_ref = from_ref_cached(project_path) - reference = parent_from_ref.to_human_reference(parent) - - label_suffix = " <i>in #{ERB::Util.html_escape(reference)}</i>" if reference.present? - end - - presenter = object.present(issuable_subject: parent) - LabelsHelper.render_colored_label(presenter, suffix: label_suffix) - end - - def wrap_link(link, label) - presenter = label.present(issuable_subject: project || group) - LabelsHelper.wrap_label_html(link, small: true, label: presenter) - end - - def full_path_ref?(matches) - matches[:namespace] && matches[:project] - end - - def reference_class(type, tooltip: true) - super + ' gl-link gl-label-link' - end - - def object_link_title(object, matches) - presenter = object.present(issuable_subject: project || group) - LabelsHelper.label_tooltip_title(presenter) - end - end - end -end - -Banzai::Filter::LabelReferenceFilter.prepend_if_ee('EE::Banzai::Filter::LabelReferenceFilter') diff --git a/lib/banzai/filter/math_filter.rb b/lib/banzai/filter/math_filter.rb index c915f0ee35b..2247984b86d 100644 --- a/lib/banzai/filter/math_filter.rb +++ b/lib/banzai/filter/math_filter.rb @@ -39,7 +39,7 @@ module Banzai end end - doc.css('pre.code.math').each do |el| + doc.css('pre.code.language-math').each do |el| el[STYLE_ATTRIBUTE] = 'display' el[:class] += " #{TAG_CLASS}" end diff --git a/lib/banzai/filter/merge_request_reference_filter.rb b/lib/banzai/filter/merge_request_reference_filter.rb deleted file mode 100644 index 0b8bd17a71b..00000000000 --- a/lib/banzai/filter/merge_request_reference_filter.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # 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 < IssuableReferenceFilter - self.reference_type = :merge_request - - def self.object_class - MergeRequest - end - - def url_for_object(mr, project) - h = Gitlab::Routing.url_helpers - h.project_merge_request_url(project, mr, - only_path: context[:only_path]) - end - - def object_link_title(object, matches) - # The method will return `nil` if object is not a commit - # allowing for properly handling the extended MR Tooltip - object_link_commit_title(object, matches) - end - - def object_link_text_extras(object, matches) - extras = super - - if commit_ref = object_link_commit_ref(object, matches) - klass = reference_class(:commit, tooltip: false) - commit_ref_tag = %(<span class="#{klass}">#{commit_ref}</span>) - - return extras.unshift(commit_ref_tag) - end - - path = matches[:path] if matches.names.include?("path") - - case path - when '/diffs' - extras.unshift "diffs" - when '/commits' - extras.unshift "commits" - when '/builds' - extras.unshift "builds" - end - - extras - end - - def parent_records(parent, ids) - parent.merge_requests - .where(iid: ids.to_a) - .includes(target_project: :namespace) - end - - def reference_class(object_sym, options = {}) - super(object_sym, tooltip: false) - end - - def data_attributes_for(text, parent, object, **data) - super.merge(project_path: parent.full_path, iid: object.iid, mr_title: object.title) - end - - private - - def object_link_commit_title(object, matches) - object_link_commit(object, matches)&.title - end - - def object_link_commit_ref(object, matches) - object_link_commit(object, matches)&.short_id - end - - def object_link_commit(object, matches) - return unless matches.names.include?('query') && query = matches[:query] - - # Removes leading "?". CGI.parse expects "arg1&arg2&arg3" - params = CGI.parse(query.sub(/^\?/, '')) - - return unless commit_sha = params['commit_id']&.first - - if commit = find_commit_by_sha(object, commit_sha) - Commit.from_hash(commit.to_hash, object.project) - end - end - - def find_commit_by_sha(object, commit_sha) - @all_commits ||= {} - @all_commits[object.id] ||= object.all_commits - - @all_commits[object.id].find { |commit| commit.sha == commit_sha } - end - end - end -end diff --git a/lib/banzai/filter/milestone_reference_filter.rb b/lib/banzai/filter/milestone_reference_filter.rb deleted file mode 100644 index 126208db935..00000000000 --- a/lib/banzai/filter/milestone_reference_filter.rb +++ /dev/null @@ -1,138 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # HTML filter that replaces milestone references with links. - class MilestoneReferenceFilter < AbstractReferenceFilter - include Gitlab::Utils::StrongMemoize - - self.reference_type = :milestone - - def self.object_class - Milestone - end - - # Links to project milestones contain the IID, but when we're handling - # 'regular' references, we need to use the global ID to disambiguate - # between group and project milestones. - def find_object(parent, id) - return unless valid_context?(parent) - - find_milestone_with_finder(parent, id: id) - end - - def find_object_from_link(parent, iid) - return unless valid_context?(parent) - - find_milestone_with_finder(parent, iid: iid) - end - - def valid_context?(parent) - strong_memoize(:valid_context) do - group_context?(parent) || project_context?(parent) - end - end - - def group_context?(parent) - strong_memoize(:group_context) do - parent.is_a?(Group) - end - end - - def project_context?(parent) - strong_memoize(:project_context) do - parent.is_a?(Project) - end - end - - def references_in(text, pattern = Milestone.reference_pattern) - # We'll handle here the references that follow the `reference_pattern`. - # Other patterns (for example, the link pattern) are handled by the - # default implementation. - return super(text, pattern) if pattern != Milestone.reference_pattern - - milestones = {} - unescaped_html = unescape_html_entities(text).gsub(pattern) do |match| - milestone = find_milestone($~[:project], $~[:namespace], $~[:milestone_iid], $~[:milestone_name]) - - if milestone - milestones[milestone.id] = yield match, milestone.id, $~[:project], $~[:namespace], $~ - "#{REFERENCE_PLACEHOLDER}#{milestone.id}" - else - match - end - end - - return text if milestones.empty? - - escape_with_placeholders(unescaped_html, milestones) - end - - def find_milestone(project_ref, namespace_ref, milestone_id, milestone_name) - project_path = full_project_path(namespace_ref, project_ref) - - # Returns group if project is not found by path - parent = parent_from_ref(project_path) - - return unless parent - - milestone_params = milestone_params(milestone_id, milestone_name) - - find_milestone_with_finder(parent, milestone_params) - end - - def milestone_params(iid, name) - if name - { name: name.tr('"', '') } - else - { iid: iid.to_i } - end - end - - def find_milestone_with_finder(parent, params) - finder_params = milestone_finder_params(parent, params[:iid].present?) - - MilestonesFinder.new(finder_params).find_by(params) - end - - def milestone_finder_params(parent, find_by_iid) - { order: nil, state: 'all' }.tap do |params| - params[:project_ids] = parent.id if project_context?(parent) - - # We don't support IID lookups because IIDs can clash between - # group/project milestones and group/subgroup milestones. - params[:group_ids] = self_and_ancestors_ids(parent) unless find_by_iid - end - end - - def self_and_ancestors_ids(parent) - if group_context?(parent) - parent.self_and_ancestors.select(:id) - elsif project_context?(parent) - parent.group&.self_and_ancestors&.select(:id) - end - end - - def url_for_object(milestone, project) - Gitlab::Routing - .url_helpers - .milestone_url(milestone, only_path: context[:only_path]) - end - - def object_link_text(object, matches) - milestone_link = escape_once(super) - reference = object.project&.to_reference_base(project) - - if reference.present? - "#{milestone_link} <i>in #{reference}</i>".html_safe - else - milestone_link - end - end - - def object_link_title(object, matches) - nil - end - end - end -end diff --git a/lib/banzai/filter/project_reference_filter.rb b/lib/banzai/filter/project_reference_filter.rb deleted file mode 100644 index 50e23460cb8..00000000000 --- a/lib/banzai/filter/project_reference_filter.rb +++ /dev/null @@ -1,117 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # HTML filter that replaces project references with links. - class ProjectReferenceFilter < ReferenceFilter - self.reference_type = :project - - # Public: Find `namespace/project>` project references in text - # - # ProjectReferenceFilter.references_in(text) do |match, project| - # "<a href=...>#{project}></a>" - # end - # - # text - String text to search. - # - # Yields the String match, and the String project name. - # - # Returns a String replaced with the return of the block. - def self.references_in(text) - text.gsub(Project.markdown_reference_pattern) do |match| - yield match, "#{$~[:namespace]}/#{$~[:project]}" - end - end - - def call - ref_pattern = Project.markdown_reference_pattern - ref_pattern_start = /\A#{ref_pattern}\z/ - - nodes.each_with_index do |node, index| - if text_node?(node) - replace_text_when_pattern_matches(node, index, ref_pattern) do |content| - project_link_filter(content) - end - elsif element_node?(node) - yield_valid_link(node) do |link, inner_html| - if link =~ ref_pattern_start - replace_link_node_with_href(node, index, link) do - project_link_filter(link, link_content: inner_html) - end - end - end - end - end - - doc - end - - # Replace `namespace/project>` project references in text with links to the referenced - # project page. - # - # text - String text to replace references in. - # link_content - Original content of the link being replaced. - # - # Returns a String with `namespace/project>` references replaced with links. All links - # have `gfm` and `gfm-project` class names attached for styling. - def project_link_filter(text, link_content: nil) - self.class.references_in(text) do |match, project_path| - cached_call(:banzai_url_for_object, match, path: [Project, project_path.downcase]) do - if project = projects_hash[project_path.downcase] - link_to_project(project, link_content: link_content) || match - else - match - end - end - end - end - - # Returns a Hash containing all Project objects for the project - # references in the current document. - # - # The keys of this Hash are the project paths, the values the - # corresponding Project objects. - def projects_hash - @projects ||= Project.eager_load(:route, namespace: [:route]) - .where_full_path_in(projects) - .index_by(&:full_path) - .transform_keys(&:downcase) - end - - # Returns all projects referenced in the current document. - def projects - refs = Set.new - - nodes.each do |node| - node.to_html.scan(Project.markdown_reference_pattern) do - refs << "#{$~[:namespace]}/#{$~[:project]}" - end - end - - refs.to_a - end - - private - - def urls - Gitlab::Routing.url_helpers - end - - def link_class - reference_class(:project) - end - - def link_to_project(project, link_content: nil) - url = urls.project_url(project, only_path: context[:only_path]) - data = data_attribute(project: project.id) - content = link_content || project.to_reference - - link_tag(url, data, content, project.name) - end - - def link_tag(url, data, link_content, title) - %(<a href="#{url}" #{data} class="#{link_class}" title="#{escape_once(title)}">#{link_content}</a>) - end - end - end -end diff --git a/lib/banzai/filter/reference_filter.rb b/lib/banzai/filter/reference_filter.rb deleted file mode 100644 index d22a0e0b504..00000000000 --- a/lib/banzai/filter/reference_filter.rb +++ /dev/null @@ -1,215 +0,0 @@ -# frozen_string_literal: true - -# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/reference.js -module Banzai - module Filter - # 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. - # :only_path - Generate path-only links. - class ReferenceFilter < HTML::Pipeline::Filter - include RequestStoreReferenceCache - include OutputSafety - - class << self - attr_accessor :reference_type - - def call(doc, context = nil, result = nil) - new(doc, context, result).call_and_update_nodes - end - end - - def initialize(doc, context = nil, result = nil) - super - - @new_nodes = {} - @nodes = self.result[:reference_filter_nodes] - end - - def call_and_update_nodes - with_update_nodes { call } - end - - # Returns a data attribute String to attach to a reference link - # - # attributes - Hash, where the key becomes the data attribute name and the - # value is the data attribute value - # - # Examples: - # - # data_attribute(project: 1, issue: 2) - # # => "data-reference-type=\"SomeReferenceFilter\" data-project=\"1\" data-issue=\"2\"" - # - # data_attribute(project: 3, merge_request: 4) - # # => "data-reference-type=\"SomeReferenceFilter\" data-project=\"3\" data-merge-request=\"4\"" - # - # Returns a String - def data_attribute(attributes = {}) - attributes = attributes.reject { |_, v| v.nil? } - - attributes[:reference_type] ||= self.class.reference_type - attributes[:container] ||= 'body' - attributes[:placement] ||= 'top' - attributes.delete(:original) if context[:no_original_data] - attributes.map do |key, value| - %Q(data-#{key.to_s.dasherize}="#{escape_once(value)}") - end.join(' ') - end - - def ignore_ancestor_query - @ignore_ancestor_query ||= begin - parents = %w(pre code a style) - parents << 'blockquote' if context[:ignore_blockquotes] - - parents.map { |n| "ancestor::#{n}" }.join(' or ') - end - end - - def project - context[:project] - end - - def group - context[:group] - end - - def user - context[:user] - end - - def skip_project_check? - context[:skip_project_check] - end - - def reference_class(type, tooltip: true) - gfm_klass = "gfm gfm-#{type}" - - return gfm_klass unless tooltip - - "#{gfm_klass} has-tooltip" - 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 unless skip_project_check? - end - - # Iterates over all <a> and text() nodes in a document. - # - # Nodes are skipped whenever their ancestor is one of the nodes returned - # by `ignore_ancestor_query`. Link tags are not processed if they have a - # "gfm" class or the "href" attribute is empty. - def each_node - return to_enum(__method__) unless block_given? - - doc.xpath(query).each do |node| - yield node - end - end - - # Returns an Array containing all HTML nodes. - def nodes - @nodes ||= each_node.to_a - end - - # Yields the link's URL and inner HTML whenever the node is a valid <a> tag. - def yield_valid_link(node) - link = unescape_link(node.attr('href').to_s) - inner_html = node.inner_html - - return unless link.force_encoding('UTF-8').valid_encoding? - - yield link, inner_html - end - - def unescape_link(href) - CGI.unescape(href) - end - - def replace_text_when_pattern_matches(node, index, pattern) - return unless node.text =~ pattern - - content = node.to_html - html = yield content - - replace_text_with_html(node, index, html) unless html == content - end - - def replace_link_node_with_text(node, index) - html = yield - - replace_text_with_html(node, index, html) unless html == node.text - end - - def replace_link_node_with_href(node, index, link) - html = yield - - replace_text_with_html(node, index, html) unless html == link - end - - def text_node?(node) - node.is_a?(Nokogiri::XML::Text) - end - - def element_node?(node) - node.is_a?(Nokogiri::XML::Element) - end - - private - - def query - @query ||= %Q{descendant-or-self::text()[not(#{ignore_ancestor_query})] - | descendant-or-self::a[ - not(contains(concat(" ", @class, " "), " gfm ")) and not(@href = "") - ]} - end - - def replace_text_with_html(node, index, html) - replace_and_update_new_nodes(node, index, html) - end - - def replace_and_update_new_nodes(node, index, html) - previous_node = node.previous - next_node = node.next - parent_node = node.parent - # Unfortunately node.replace(html) returns re-parented nodes, not the actual replaced nodes in the doc - # We need to find the actual nodes in the doc that were replaced - node.replace(html) - @new_nodes[index] = [] - - # We replaced node with new nodes, so we find first new node. If previous_node is nil, we take first parent child - new_node = previous_node ? previous_node.next : parent_node&.children&.first - - # We iterate from first to last replaced node and store replaced nodes in @new_nodes - while new_node && new_node != next_node - @new_nodes[index] << new_node.xpath(query) - new_node = new_node.next - end - - @new_nodes[index].flatten! - end - - def only_path? - context[:only_path] - end - - def with_update_nodes - @new_nodes = {} - yield.tap { update_nodes! } - end - - # Once Filter completes replacing nodes, we update nodes with @new_nodes - def update_nodes! - @new_nodes.sort_by { |index, _new_nodes| -index }.each do |index, new_nodes| - nodes[index, 1] = new_nodes - end - result[:reference_filter_nodes] = nodes - end - end - end -end diff --git a/lib/banzai/filter/references/abstract_reference_filter.rb b/lib/banzai/filter/references/abstract_reference_filter.rb new file mode 100644 index 00000000000..7109373dbce --- /dev/null +++ b/lib/banzai/filter/references/abstract_reference_filter.rb @@ -0,0 +1,448 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + # Issues, merge requests, Snippets, Commits and Commit Ranges share + # similar functionality in reference filtering. + class AbstractReferenceFilter < ReferenceFilter + include CrossProjectReference + + # REFERENCE_PLACEHOLDER is used for re-escaping HTML text except found + # reference (which we replace with placeholder during re-scaping). The + # random number helps ensure it's pretty close to unique. Since it's a + # transitory value (it never gets saved) we can initialize once, and it + # doesn't matter if it changes on a restart. + REFERENCE_PLACEHOLDER = "_reference_#{SecureRandom.hex(16)}_" + REFERENCE_PLACEHOLDER_PATTERN = %r{#{REFERENCE_PLACEHOLDER}(\d+)}.freeze + + def self.object_class + # Implement in child class + # Example: MergeRequest + end + + def self.object_name + @object_name ||= object_class.name.underscore + end + + def self.object_sym + @object_sym ||= object_name.to_sym + end + + # Public: Find references in text (like `!123` for merge requests) + # + # AnyReferenceFilter.references_in(text) do |match, id, project_ref, matches| + # object = find_object(project_ref, id) + # "<a href=...>#{object.to_reference}</a>" + # end + # + # text - String text to search. + # + # Yields the String match, the Integer referenced object ID, an optional String + # of the external project reference, and all of the matchdata. + # + # Returns a String replaced with the return of the block. + def self.references_in(text, pattern = object_class.reference_pattern) + text.gsub(pattern) do |match| + if ident = identifier($~) + yield match, ident, $~[:project], $~[:namespace], $~ + else + match + end + end + end + + def self.identifier(match_data) + symbol = symbol_from_match(match_data) + + parse_symbol(symbol, match_data) if object_class.reference_valid?(symbol) + end + + def identifier(match_data) + self.class.identifier(match_data) + end + + def self.symbol_from_match(match) + key = object_sym + match[key] if match.names.include?(key.to_s) + end + + # Transform a symbol extracted from the text to a meaningful value + # In most cases these will be integers, so we call #to_i by default + # + # This method has the contract that if a string `ref` refers to a + # record `record`, then `parse_symbol(ref) == record_identifier(record)`. + def self.parse_symbol(symbol, match_data) + symbol.to_i + end + + # We assume that most classes are identifying records by ID. + # + # This method has the contract that if a string `ref` refers to a + # record `record`, then `class.parse_symbol(ref) == record_identifier(record)`. + def record_identifier(record) + record.id + end + + def object_class + self.class.object_class + end + + def object_sym + self.class.object_sym + end + + def references_in(*args, &block) + self.class.references_in(*args, &block) + end + + # Implement in child class + # Example: project.merge_requests.find + def find_object(parent_object, id) + end + + # Override if the link reference pattern produces a different ID (global + # ID vs internal ID, for instance) to the regular reference pattern. + def find_object_from_link(parent_object, id) + find_object(parent_object, id) + end + + # Implement in child class + # Example: project_merge_request_url + def url_for_object(object, parent_object) + end + + def find_object_cached(parent_object, id) + cached_call(:banzai_find_object, id, path: [object_class, parent_object.id]) do + find_object(parent_object, id) + end + end + + def find_object_from_link_cached(parent_object, id) + cached_call(:banzai_find_object_from_link, id, path: [object_class, parent_object.id]) do + find_object_from_link(parent_object, id) + end + end + + def from_ref_cached(ref) + cached_call("banzai_#{parent_type}_refs".to_sym, ref) do + parent_from_ref(ref) + end + end + + def url_for_object_cached(object, parent_object) + cached_call(:banzai_url_for_object, object, path: [object_class, parent_object.id]) do + url_for_object(object, parent_object) + end + end + + def call + return doc unless project || group || user + + ref_pattern = object_class.reference_pattern + link_pattern = object_class.link_reference_pattern + + # Compile often used regexps only once outside of the loop + ref_pattern_anchor = /\A#{ref_pattern}\z/ + link_pattern_start = /\A#{link_pattern}/ + link_pattern_anchor = /\A#{link_pattern}\z/ + + nodes.each_with_index do |node, index| + if text_node?(node) && ref_pattern + replace_text_when_pattern_matches(node, index, ref_pattern) do |content| + object_link_filter(content, ref_pattern) + end + + elsif element_node?(node) + yield_valid_link(node) do |link, inner_html| + if ref_pattern && link =~ ref_pattern_anchor + replace_link_node_with_href(node, index, link) do + object_link_filter(link, ref_pattern, link_content: inner_html) + end + + next + end + + next unless link_pattern + + if link == inner_html && inner_html =~ link_pattern_start + replace_link_node_with_text(node, index) do + object_link_filter(inner_html, link_pattern, link_reference: true) + end + + next + end + + if link =~ link_pattern_anchor + replace_link_node_with_href(node, index, link) do + object_link_filter(link, link_pattern, link_content: inner_html, link_reference: true) + end + + next + end + end + end + end + + doc + end + + # Replace references (like `!123` for merge requests) in text with links + # to the referenced object's details page. + # + # text - String text to replace references in. + # pattern - Reference pattern to match against. + # link_content - Original content of the link being replaced. + # link_reference - True if this was using the link reference pattern, + # false otherwise. + # + # Returns a String with references replaced with links. All links + # have `gfm` and `gfm-OBJECT_NAME` class names attached for styling. + def object_link_filter(text, pattern, link_content: nil, link_reference: false) + references_in(text, pattern) do |match, id, project_ref, namespace_ref, matches| + parent_path = if parent_type == :group + full_group_path(namespace_ref) + else + full_project_path(namespace_ref, project_ref) + end + + parent = from_ref_cached(parent_path) + + if parent + object = + if link_reference + find_object_from_link_cached(parent, id) + else + find_object_cached(parent, id) + end + end + + if object + title = object_link_title(object, matches) + klass = reference_class(object_sym) + + data_attributes = data_attributes_for(link_content || match, parent, object, + link_content: !!link_content, + link_reference: link_reference) + data = data_attribute(data_attributes) + + url = + if matches.names.include?("url") && matches[:url] + matches[:url] + else + url_for_object_cached(object, parent) + end + + content = link_content || object_link_text(object, matches) + + link = %(<a href="#{url}" #{data} + title="#{escape_once(title)}" + class="#{klass}">#{content}</a>) + + wrap_link(link, object) + else + match + end + end + end + + def wrap_link(link, object) + link + end + + def data_attributes_for(text, parent, object, link_content: false, link_reference: false) + object_parent_type = parent.is_a?(Group) ? :group : :project + + { + original: escape_html_entities(text), + link: link_content, + link_reference: link_reference, + object_parent_type => parent.id, + object_sym => object.id + } + end + + def object_link_text_extras(object, matches) + extras = [] + + if matches.names.include?("anchor") && matches[:anchor] && matches[:anchor] =~ /\A\#note_(\d+)\z/ + extras << "comment #{Regexp.last_match(1)}" + end + + extension = matches[:extension] if matches.names.include?("extension") + + extras << extension if extension + + extras + end + + def object_link_title(object, matches) + object.title + end + + def object_link_text(object, matches) + parent = project || group || user + text = object.reference_link_text(parent) + + extras = object_link_text_extras(object, matches) + text += " (#{extras.join(", ")})" if extras.any? + + text + end + + # Returns a Hash containing all object references (e.g. issue IDs) per the + # project they belong to. + def references_per_parent + @references_per ||= {} + + @references_per[parent_type] ||= begin + refs = Hash.new { |hash, key| hash[key] = Set.new } + regex = [ + object_class.link_reference_pattern, + object_class.reference_pattern + ].compact.reduce { |a, b| Regexp.union(a, b) } + + nodes.each do |node| + node.to_html.scan(regex) do + path = if parent_type == :project + full_project_path($~[:namespace], $~[:project]) + else + full_group_path($~[:group]) + end + + if ident = identifier($~) + refs[path] << ident + end + end + end + + refs + end + end + + # Returns a Hash containing referenced projects grouped per their full + # path. + def parent_per_reference + @per_reference ||= {} + + @per_reference[parent_type] ||= begin + refs = Set.new + + references_per_parent.each do |ref, _| + refs << ref + end + + find_for_paths(refs.to_a).index_by(&:full_path) + end + end + + def relation_for_paths(paths) + klass = parent_type.to_s.camelize.constantize + result = klass.where_full_path_in(paths) + return result if parent_type == :group + + result.includes(:namespace) if parent_type == :project + end + + # Returns projects for the given paths. + def find_for_paths(paths) + if Gitlab::SafeRequestStore.active? + cache = refs_cache + to_query = paths - cache.keys + + unless to_query.empty? + records = relation_for_paths(to_query) + + found = [] + records.each do |record| + ref = record.full_path + get_or_set_cache(cache, ref) { record } + found << ref + end + + not_found = to_query - found + not_found.each do |ref| + get_or_set_cache(cache, ref) { nil } + end + end + + cache.slice(*paths).values.compact + else + relation_for_paths(paths) + end + end + + def current_parent_path + @current_parent_path ||= parent&.full_path + end + + def current_project_namespace_path + @current_project_namespace_path ||= project&.namespace&.full_path + end + + def records_per_parent + @_records_per_project ||= {} + + @_records_per_project[object_class.to_s.underscore] ||= begin + hash = Hash.new { |h, k| h[k] = {} } + + parent_per_reference.each do |path, parent| + record_ids = references_per_parent[path] + + parent_records(parent, record_ids).each do |record| + hash[parent][record_identifier(record)] = record + end + end + + hash + end + end + + private + + def full_project_path(namespace, project_ref) + return current_parent_path unless project_ref + + namespace_ref = namespace || current_project_namespace_path + "#{namespace_ref}/#{project_ref}" + end + + def refs_cache + Gitlab::SafeRequestStore["banzai_#{parent_type}_refs".to_sym] ||= {} + end + + def parent_type + :project + end + + def parent + parent_type == :project ? project : group + end + + def full_group_path(group_ref) + return current_parent_path unless group_ref + + group_ref + end + + def unescape_html_entities(text) + CGI.unescapeHTML(text.to_s) + end + + def escape_html_entities(text) + CGI.escapeHTML(text.to_s) + end + + def escape_with_placeholders(text, placeholder_data) + escaped = escape_html_entities(text) + + escaped.gsub(REFERENCE_PLACEHOLDER_PATTERN) do |match| + placeholder_data[Regexp.last_match(1).to_i] + end + end + end + end + end +end + +Banzai::Filter::References::AbstractReferenceFilter.prepend_if_ee('EE::Banzai::Filter::References::AbstractReferenceFilter') diff --git a/lib/banzai/filter/references/alert_reference_filter.rb b/lib/banzai/filter/references/alert_reference_filter.rb new file mode 100644 index 00000000000..90fef536605 --- /dev/null +++ b/lib/banzai/filter/references/alert_reference_filter.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + class AlertReferenceFilter < IssuableReferenceFilter + self.reference_type = :alert + + def self.object_class + AlertManagement::Alert + end + + def self.object_sym + :alert + end + + def parent_records(parent, ids) + parent.alert_management_alerts.where(iid: ids.to_a) + end + + def url_for_object(alert, project) + ::Gitlab::Routing.url_helpers.details_project_alert_management_url( + project, + alert.iid, + only_path: context[:only_path] + ) + end + end + end + end +end diff --git a/lib/banzai/filter/references/commit_range_reference_filter.rb b/lib/banzai/filter/references/commit_range_reference_filter.rb new file mode 100644 index 00000000000..ad79f8a173c --- /dev/null +++ b/lib/banzai/filter/references/commit_range_reference_filter.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + # HTML filter that replaces commit range references with links. + # + # This filter supports cross-project references. + class CommitRangeReferenceFilter < AbstractReferenceFilter + self.reference_type = :commit_range + + def self.object_class + CommitRange + end + + def self.references_in(text, pattern = CommitRange.reference_pattern) + text.gsub(pattern) do |match| + yield match, $~[:commit_range], $~[:project], $~[:namespace], $~ + end + end + + def initialize(*args) + super + + @commit_map = {} + end + + def find_object(project, id) + return unless project.is_a?(Project) + + range = CommitRange.new(id, project) + + range.valid_commits? ? range : nil + end + + def url_for_object(range, project) + h = Gitlab::Routing.url_helpers + h.project_compare_url(project, + range.to_param.merge(only_path: context[:only_path])) + end + + def object_link_title(range, matches) + nil + end + end + end + end +end diff --git a/lib/banzai/filter/references/commit_reference_filter.rb b/lib/banzai/filter/references/commit_reference_filter.rb new file mode 100644 index 00000000000..457921bd07d --- /dev/null +++ b/lib/banzai/filter/references/commit_reference_filter.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + # HTML filter that replaces commit references with links. + # + # This filter supports cross-project references. + class CommitReferenceFilter < AbstractReferenceFilter + self.reference_type = :commit + + def self.object_class + Commit + end + + def self.references_in(text, pattern = Commit.reference_pattern) + text.gsub(pattern) do |match| + yield match, $~[:commit], $~[:project], $~[:namespace], $~ + end + end + + def find_object(project, id) + return unless project.is_a?(Project) && project.valid_repo? + + _, record = records_per_parent[project].detect { |k, _v| Gitlab::Git.shas_eql?(k, id) } + + record + end + + def referenced_merge_request_commit_shas + return [] unless noteable.is_a?(MergeRequest) + + @referenced_merge_request_commit_shas ||= begin + referenced_shas = references_per_parent.values.reduce(:|).to_a + noteable.all_commit_shas.select do |sha| + referenced_shas.any? { |ref| Gitlab::Git.shas_eql?(sha, ref) } + end + end + end + + # The default behaviour is `#to_i` - we just pass the hash through. + def self.parse_symbol(sha_hash, _match) + sha_hash + end + + def url_for_object(commit, project) + h = Gitlab::Routing.url_helpers + + if referenced_merge_request_commit_shas.include?(commit.id) + h.diffs_project_merge_request_url(project, + noteable, + commit_id: commit.id, + only_path: only_path?) + else + h.project_commit_url(project, + commit, + only_path: only_path?) + end + end + + def object_link_text_extras(object, matches) + extras = super + + path = matches[:path] if matches.names.include?("path") + if path == '/builds' + extras.unshift "builds" + end + + extras + end + + private + + def parent_records(parent, ids) + parent.commits_by(oids: ids.to_a) + end + + def noteable + context[:noteable] + end + + def only_path? + context[:only_path] + end + end + end + end +end diff --git a/lib/banzai/filter/references/design_reference_filter.rb b/lib/banzai/filter/references/design_reference_filter.rb new file mode 100644 index 00000000000..61234e61c15 --- /dev/null +++ b/lib/banzai/filter/references/design_reference_filter.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + class DesignReferenceFilter < AbstractReferenceFilter + class Identifier + include Comparable + attr_reader :issue_iid, :filename + + def initialize(issue_iid:, filename:) + @issue_iid = issue_iid + @filename = filename + end + + def as_composite_id(id_for_iid) + id = id_for_iid[issue_iid] + return unless id + + { issue_id: id, filename: filename } + end + + def <=>(other) + return unless other.is_a?(Identifier) + + [issue_iid, filename] <=> [other.issue_iid, other.filename] + end + alias_method :eql?, :== + + def hash + [issue_iid, filename].hash + end + end + + self.reference_type = :design + + def find_object(project, identifier) + records_per_parent[project][identifier] + end + + def parent_records(project, identifiers) + return [] unless project.design_management_enabled? + + iids = identifiers.map(&:issue_iid).to_set + issues = project.issues.where(iid: iids) + id_for_iid = issues.index_by(&:iid).transform_values(&:id) + issue_by_id = issues.index_by(&:id) + + designs(identifiers, id_for_iid).each do |d| + issue = issue_by_id[d.issue_id] + # optimisation: assign values we have already fetched + d.project = project + d.issue = issue + end + end + + def relation_for_paths(paths) + super.includes(:route, :namespace, :group) + end + + def parent_type + :project + end + + # optimisation to reuse the parent_per_reference query information + def parent_from_ref(ref) + parent_per_reference[ref || current_parent_path] + end + + def url_for_object(design, project) + path_options = { vueroute: design.filename } + Gitlab::Routing.url_helpers.designs_project_issue_path(project, design.issue, path_options) + end + + def data_attributes_for(_text, _project, design, **_kwargs) + super.merge(issue: design.issue_id) + end + + def self.object_class + ::DesignManagement::Design + end + + def self.object_sym + :design + end + + def self.parse_symbol(raw, match_data) + filename = match_data[:url_filename] + iid = match_data[:issue].to_i + Identifier.new(filename: CGI.unescape(filename), issue_iid: iid) + end + + def record_identifier(design) + Identifier.new(filename: design.filename, issue_iid: design.issue.iid) + end + + private + + def designs(identifiers, id_for_iid) + identifiers + .map { |identifier| identifier.as_composite_id(id_for_iid) } + .compact + .in_groups_of(100, false) # limitation of by_issue_id_and_filename, so we batch + .flat_map { |ids| DesignManagement::Design.by_issue_id_and_filename(ids) } + end + end + end + end +end diff --git a/lib/banzai/filter/references/epic_reference_filter.rb b/lib/banzai/filter/references/epic_reference_filter.rb new file mode 100644 index 00000000000..4ee446e5317 --- /dev/null +++ b/lib/banzai/filter/references/epic_reference_filter.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + # The actual filter is implemented in the EE mixin + class EpicReferenceFilter < IssuableReferenceFilter + self.reference_type = :epic + + def self.object_class + Epic + end + + private + + def group + context[:group] || context[:project]&.group + end + end + end + end +end + +Banzai::Filter::References::EpicReferenceFilter.prepend_if_ee('EE::Banzai::Filter::References::EpicReferenceFilter') diff --git a/lib/banzai/filter/references/external_issue_reference_filter.rb b/lib/banzai/filter/references/external_issue_reference_filter.rb new file mode 100644 index 00000000000..247e20967df --- /dev/null +++ b/lib/banzai/filter/references/external_issue_reference_filter.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + # HTML filter that replaces external issue tracker references with links. + # References are ignored if the project doesn't use an external issue + # tracker. + # + # This filter does not support cross-project references. + class ExternalIssueReferenceFilter < ReferenceFilter + self.reference_type = :external_issue + + # Public: Find `JIRA-123` issue references in text + # + # ExternalIssueReferenceFilter.references_in(text, pattern) 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, pattern) + text.gsub(pattern) do |match| + yield match, $~[:issue] + end + end + + def call + # Early return if the project isn't using an external tracker + return doc if project.nil? || default_issues_tracker? + + ref_pattern = issue_reference_pattern + ref_start_pattern = /\A#{ref_pattern}\z/ + + nodes.each_with_index do |node, index| + if text_node?(node) + replace_text_when_pattern_matches(node, index, ref_pattern) do |content| + issue_link_filter(content) + end + + elsif element_node?(node) + yield_valid_link(node) do |link, inner_html| + if link =~ ref_start_pattern + replace_link_node_with_href(node, index, link) do + issue_link_filter(link, link_content: inner_html) + end + end + end + end + end + + doc + end + + private + + # Replace `JIRA-123` issue references in text with links to the referenced + # issue's details page. + # + # text - String text to replace references in. + # link_content - Original content of the link being replaced. + # + # 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, link_content: nil) + self.class.references_in(text, issue_reference_pattern) do |match, id| + url = url_for_issue(id) + klass = reference_class(:issue) + data = data_attribute(project: project.id, external_issue: id) + content = link_content || match + + %(<a href="#{url}" #{data} + title="#{escape_once(issue_title)}" + class="#{klass}">#{content}</a>) + end + end + + def url_for_issue(issue_id) + return '' if project.nil? + + url = if only_path? + project.external_issue_tracker.issue_path(issue_id) + else + project.external_issue_tracker.issue_url(issue_id) + end + + # Ensure we return a valid URL to prevent possible XSS. + URI.parse(url).to_s + rescue URI::InvalidURIError + '' + end + + def default_issues_tracker? + external_issues_cached(:default_issues_tracker?) + end + + def issue_reference_pattern + external_issues_cached(:external_issue_reference_pattern) + end + + def project + context[:project] + end + + def issue_title + "Issue in #{project.external_issue_tracker.title}" + end + + def external_issues_cached(attribute) + cached_attributes = Gitlab::SafeRequestStore[:banzai_external_issues_tracker_attributes] ||= Hash.new { |h, k| h[k] = {} } + cached_attributes[project.id][attribute] = project.public_send(attribute) if cached_attributes[project.id][attribute].nil? # rubocop:disable GitlabSecurity/PublicSend + cached_attributes[project.id][attribute] + end + end + end + end +end diff --git a/lib/banzai/filter/references/feature_flag_reference_filter.rb b/lib/banzai/filter/references/feature_flag_reference_filter.rb new file mode 100644 index 00000000000..be9ded1ff43 --- /dev/null +++ b/lib/banzai/filter/references/feature_flag_reference_filter.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + class FeatureFlagReferenceFilter < IssuableReferenceFilter + self.reference_type = :feature_flag + + def self.object_class + Operations::FeatureFlag + end + + def self.object_sym + :feature_flag + end + + def parent_records(parent, ids) + parent.operations_feature_flags.where(iid: ids.to_a) + end + + def url_for_object(feature_flag, project) + ::Gitlab::Routing.url_helpers.edit_project_feature_flag_url( + project, + feature_flag.iid, + only_path: context[:only_path] + ) + end + + def object_link_title(object, matches) + object.name + end + end + end + end +end diff --git a/lib/banzai/filter/references/issuable_reference_filter.rb b/lib/banzai/filter/references/issuable_reference_filter.rb new file mode 100644 index 00000000000..b8ccb926ae9 --- /dev/null +++ b/lib/banzai/filter/references/issuable_reference_filter.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + class IssuableReferenceFilter < AbstractReferenceFilter + def record_identifier(record) + record.iid.to_i + end + + def find_object(parent, iid) + records_per_parent[parent][iid] + end + + def parent_from_ref(ref) + parent_per_reference[ref || current_parent_path] + end + end + end + end +end diff --git a/lib/banzai/filter/references/issue_reference_filter.rb b/lib/banzai/filter/references/issue_reference_filter.rb new file mode 100644 index 00000000000..eacf261b15f --- /dev/null +++ b/lib/banzai/filter/references/issue_reference_filter.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + # HTML filter that replaces issue references with links. References to + # issues that do not exist are ignored. + # + # This filter supports cross-project references. + # + # When external issues tracker like Jira is activated we should not + # use issue reference pattern, but we should still be able + # to reference issues from other GitLab projects. + class IssueReferenceFilter < IssuableReferenceFilter + self.reference_type = :issue + + def self.object_class + Issue + end + + def url_for_object(issue, project) + return issue_path(issue, project) if only_path? + + issue_url(issue, project) + end + + def parent_records(parent, ids) + parent.issues.where(iid: ids.to_a) + end + + def object_link_text_extras(issue, matches) + super + design_link_extras(issue, matches.named_captures['path']) + end + + private + + def issue_path(issue, project) + Gitlab::Routing.url_helpers.namespace_project_issue_path(namespace_id: project.namespace, project_id: project, id: issue.iid) + end + + def issue_url(issue, project) + Gitlab::Routing.url_helpers.namespace_project_issue_url(namespace_id: project.namespace, project_id: project, id: issue.iid) + end + + def design_link_extras(issue, path) + if path == '/designs' && read_designs?(issue) + ['designs'] + else + [] + end + end + + def read_designs?(issue) + issue.project.design_management_enabled? + end + end + end + end +end diff --git a/lib/banzai/filter/references/iteration_reference_filter.rb b/lib/banzai/filter/references/iteration_reference_filter.rb new file mode 100644 index 00000000000..cf3d446147f --- /dev/null +++ b/lib/banzai/filter/references/iteration_reference_filter.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + # The actual filter is implemented in the EE mixin + class IterationReferenceFilter < AbstractReferenceFilter + self.reference_type = :iteration + + def self.object_class + Iteration + end + end + end + end +end + +Banzai::Filter::References::IterationReferenceFilter.prepend_if_ee('EE::Banzai::Filter::References::IterationReferenceFilter') diff --git a/lib/banzai/filter/references/label_reference_filter.rb b/lib/banzai/filter/references/label_reference_filter.rb new file mode 100644 index 00000000000..a6a5eec5d9a --- /dev/null +++ b/lib/banzai/filter/references/label_reference_filter.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + # HTML filter that replaces label references with links. + class LabelReferenceFilter < AbstractReferenceFilter + self.reference_type = :label + + def self.object_class + Label + end + + def find_object(parent_object, id) + find_labels(parent_object).find(id) + end + + def references_in(text, pattern = Label.reference_pattern) + labels = {} + unescaped_html = unescape_html_entities(text).gsub(pattern) do |match| + namespace = $~[:namespace] + project = $~[:project] + project_path = full_project_path(namespace, project) + label = find_label_cached(project_path, $~[:label_id], $~[:label_name]) + + if label + labels[label.id] = yield match, label.id, project, namespace, $~ + "#{REFERENCE_PLACEHOLDER}#{label.id}" + else + match + end + end + + return text if labels.empty? + + escape_with_placeholders(unescaped_html, labels) + end + + def find_label_cached(parent_ref, label_id, label_name) + cached_call(:banzai_find_label_cached, label_name&.tr('"', '') || label_id, path: [object_class, parent_ref]) do + find_label(parent_ref, label_id, label_name) + end + end + + def find_label(parent_ref, label_id, label_name) + parent = parent_from_ref(parent_ref) + return unless parent + + label_params = label_params(label_id, label_name) + find_labels(parent).find_by(label_params) + end + + def find_labels(parent) + params = if parent.is_a?(Group) + { group_id: parent.id, + include_ancestor_groups: true, + only_group_labels: true } + else + { project: parent, + include_ancestor_groups: true } + end + + LabelsFinder.new(nil, params).execute(skip_authorization: true) + 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 name + { name: name.tr('"', '') } + else + { id: id.to_i } + end + end + + def url_for_object(label, parent) + label_url_method = + if context[:label_url_method] + context[:label_url_method] + elsif parent.is_a?(Project) + :project_issues_url + end + + return unless label_url_method + + Gitlab::Routing.url_helpers.public_send(label_url_method, parent, label_name: label.name, only_path: context[:only_path]) # rubocop:disable GitlabSecurity/PublicSend + end + + def object_link_text(object, matches) + label_suffix = '' + parent = project || group + + if project || full_path_ref?(matches) + project_path = full_project_path(matches[:namespace], matches[:project]) + parent_from_ref = from_ref_cached(project_path) + reference = parent_from_ref.to_human_reference(parent) + + label_suffix = " <i>in #{ERB::Util.html_escape(reference)}</i>" if reference.present? + end + + presenter = object.present(issuable_subject: parent) + LabelsHelper.render_colored_label(presenter, suffix: label_suffix) + end + + def wrap_link(link, label) + presenter = label.present(issuable_subject: project || group) + LabelsHelper.wrap_label_html(link, small: true, label: presenter) + end + + def full_path_ref?(matches) + matches[:namespace] && matches[:project] + end + + def reference_class(type, tooltip: true) + super + ' gl-link gl-label-link' + end + + def object_link_title(object, matches) + presenter = object.present(issuable_subject: project || group) + LabelsHelper.label_tooltip_title(presenter) + end + end + end + end +end + +Banzai::Filter::References::LabelReferenceFilter.prepend_if_ee('EE::Banzai::Filter::References::LabelReferenceFilter') diff --git a/lib/banzai/filter/references/merge_request_reference_filter.rb b/lib/banzai/filter/references/merge_request_reference_filter.rb new file mode 100644 index 00000000000..872c33f6873 --- /dev/null +++ b/lib/banzai/filter/references/merge_request_reference_filter.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + # 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 < IssuableReferenceFilter + self.reference_type = :merge_request + + def self.object_class + MergeRequest + end + + def url_for_object(mr, project) + h = Gitlab::Routing.url_helpers + h.project_merge_request_url(project, mr, + only_path: context[:only_path]) + end + + def object_link_title(object, matches) + # The method will return `nil` if object is not a commit + # allowing for properly handling the extended MR Tooltip + object_link_commit_title(object, matches) + end + + def object_link_text_extras(object, matches) + extras = super + + if commit_ref = object_link_commit_ref(object, matches) + klass = reference_class(:commit, tooltip: false) + commit_ref_tag = %(<span class="#{klass}">#{commit_ref}</span>) + + return extras.unshift(commit_ref_tag) + end + + path = matches[:path] if matches.names.include?("path") + + case path + when '/diffs' + extras.unshift "diffs" + when '/commits' + extras.unshift "commits" + when '/builds' + extras.unshift "builds" + end + + extras + end + + def parent_records(parent, ids) + parent.merge_requests + .where(iid: ids.to_a) + .includes(target_project: :namespace) + end + + def reference_class(object_sym, options = {}) + super(object_sym, tooltip: false) + end + + def data_attributes_for(text, parent, object, **data) + super.merge(project_path: parent.full_path, iid: object.iid, mr_title: object.title) + end + + private + + def object_link_commit_title(object, matches) + object_link_commit(object, matches)&.title + end + + def object_link_commit_ref(object, matches) + object_link_commit(object, matches)&.short_id + end + + def object_link_commit(object, matches) + return unless matches.names.include?('query') && query = matches[:query] + + # Removes leading "?". CGI.parse expects "arg1&arg2&arg3" + params = CGI.parse(query.sub(/^\?/, '')) + + return unless commit_sha = params['commit_id']&.first + + if commit = find_commit_by_sha(object, commit_sha) + Commit.from_hash(commit.to_hash, object.project) + end + end + + def find_commit_by_sha(object, commit_sha) + @all_commits ||= {} + @all_commits[object.id] ||= object.all_commits + + @all_commits[object.id].find { |commit| commit.sha == commit_sha } + end + end + end + end +end diff --git a/lib/banzai/filter/references/milestone_reference_filter.rb b/lib/banzai/filter/references/milestone_reference_filter.rb new file mode 100644 index 00000000000..49110194ddc --- /dev/null +++ b/lib/banzai/filter/references/milestone_reference_filter.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + # HTML filter that replaces milestone references with links. + class MilestoneReferenceFilter < AbstractReferenceFilter + include Gitlab::Utils::StrongMemoize + + self.reference_type = :milestone + + def self.object_class + Milestone + end + + # Links to project milestones contain the IID, but when we're handling + # 'regular' references, we need to use the global ID to disambiguate + # between group and project milestones. + def find_object(parent, id) + return unless valid_context?(parent) + + find_milestone_with_finder(parent, id: id) + end + + def find_object_from_link(parent, iid) + return unless valid_context?(parent) + + find_milestone_with_finder(parent, iid: iid) + end + + def valid_context?(parent) + strong_memoize(:valid_context) do + group_context?(parent) || project_context?(parent) + end + end + + def group_context?(parent) + strong_memoize(:group_context) do + parent.is_a?(Group) + end + end + + def project_context?(parent) + strong_memoize(:project_context) do + parent.is_a?(Project) + end + end + + def references_in(text, pattern = Milestone.reference_pattern) + # We'll handle here the references that follow the `reference_pattern`. + # Other patterns (for example, the link pattern) are handled by the + # default implementation. + return super(text, pattern) if pattern != Milestone.reference_pattern + + milestones = {} + unescaped_html = unescape_html_entities(text).gsub(pattern) do |match| + milestone = find_milestone($~[:project], $~[:namespace], $~[:milestone_iid], $~[:milestone_name]) + + if milestone + milestones[milestone.id] = yield match, milestone.id, $~[:project], $~[:namespace], $~ + "#{REFERENCE_PLACEHOLDER}#{milestone.id}" + else + match + end + end + + return text if milestones.empty? + + escape_with_placeholders(unescaped_html, milestones) + end + + def find_milestone(project_ref, namespace_ref, milestone_id, milestone_name) + project_path = full_project_path(namespace_ref, project_ref) + + # Returns group if project is not found by path + parent = parent_from_ref(project_path) + + return unless parent + + milestone_params = milestone_params(milestone_id, milestone_name) + + find_milestone_with_finder(parent, milestone_params) + end + + def milestone_params(iid, name) + if name + { name: name.tr('"', '') } + else + { iid: iid.to_i } + end + end + + def find_milestone_with_finder(parent, params) + finder_params = milestone_finder_params(parent, params[:iid].present?) + + MilestonesFinder.new(finder_params).find_by(params) + end + + def milestone_finder_params(parent, find_by_iid) + { order: nil, state: 'all' }.tap do |params| + params[:project_ids] = parent.id if project_context?(parent) + + # We don't support IID lookups because IIDs can clash between + # group/project milestones and group/subgroup milestones. + params[:group_ids] = self_and_ancestors_ids(parent) unless find_by_iid + end + end + + def self_and_ancestors_ids(parent) + if group_context?(parent) + parent.self_and_ancestors.select(:id) + elsif project_context?(parent) + parent.group&.self_and_ancestors&.select(:id) + end + end + + def url_for_object(milestone, project) + Gitlab::Routing + .url_helpers + .milestone_url(milestone, only_path: context[:only_path]) + end + + def object_link_text(object, matches) + milestone_link = escape_once(super) + reference = object.project&.to_reference_base(project) + + if reference.present? + "#{milestone_link} <i>in #{reference}</i>".html_safe + else + milestone_link + end + end + + def object_link_title(object, matches) + nil + end + end + end + end +end diff --git a/lib/banzai/filter/references/project_reference_filter.rb b/lib/banzai/filter/references/project_reference_filter.rb new file mode 100644 index 00000000000..522c6e0f5f3 --- /dev/null +++ b/lib/banzai/filter/references/project_reference_filter.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + # HTML filter that replaces project references with links. + class ProjectReferenceFilter < ReferenceFilter + self.reference_type = :project + + # Public: Find `namespace/project>` project references in text + # + # ProjectReferenceFilter.references_in(text) do |match, project| + # "<a href=...>#{project}></a>" + # end + # + # text - String text to search. + # + # Yields the String match, and the String project name. + # + # Returns a String replaced with the return of the block. + def self.references_in(text) + text.gsub(Project.markdown_reference_pattern) do |match| + yield match, "#{$~[:namespace]}/#{$~[:project]}" + end + end + + def call + ref_pattern = Project.markdown_reference_pattern + ref_pattern_start = /\A#{ref_pattern}\z/ + + nodes.each_with_index do |node, index| + if text_node?(node) + replace_text_when_pattern_matches(node, index, ref_pattern) do |content| + project_link_filter(content) + end + elsif element_node?(node) + yield_valid_link(node) do |link, inner_html| + if link =~ ref_pattern_start + replace_link_node_with_href(node, index, link) do + project_link_filter(link, link_content: inner_html) + end + end + end + end + end + + doc + end + + # Replace `namespace/project>` project references in text with links to the referenced + # project page. + # + # text - String text to replace references in. + # link_content - Original content of the link being replaced. + # + # Returns a String with `namespace/project>` references replaced with links. All links + # have `gfm` and `gfm-project` class names attached for styling. + def project_link_filter(text, link_content: nil) + self.class.references_in(text) do |match, project_path| + cached_call(:banzai_url_for_object, match, path: [Project, project_path.downcase]) do + if project = projects_hash[project_path.downcase] + link_to_project(project, link_content: link_content) || match + else + match + end + end + end + end + + # Returns a Hash containing all Project objects for the project + # references in the current document. + # + # The keys of this Hash are the project paths, the values the + # corresponding Project objects. + def projects_hash + @projects ||= Project.eager_load(:route, namespace: [:route]) + .where_full_path_in(projects) + .index_by(&:full_path) + .transform_keys(&:downcase) + end + + # Returns all projects referenced in the current document. + def projects + refs = Set.new + + nodes.each do |node| + node.to_html.scan(Project.markdown_reference_pattern) do + refs << "#{$~[:namespace]}/#{$~[:project]}" + end + end + + refs.to_a + end + + private + + def urls + Gitlab::Routing.url_helpers + end + + def link_class + reference_class(:project) + end + + def link_to_project(project, link_content: nil) + url = urls.project_url(project, only_path: context[:only_path]) + data = data_attribute(project: project.id) + content = link_content || project.to_reference + + link_tag(url, data, content, project.name) + end + + def link_tag(url, data, link_content, title) + %(<a href="#{url}" #{data} class="#{link_class}" title="#{escape_once(title)}">#{link_content}</a>) + end + end + end + end +end diff --git a/lib/banzai/filter/references/reference_filter.rb b/lib/banzai/filter/references/reference_filter.rb new file mode 100644 index 00000000000..dd15c43f5d8 --- /dev/null +++ b/lib/banzai/filter/references/reference_filter.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/reference.js +module Banzai + module Filter + module References + # 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. + # :only_path - Generate path-only links. + class ReferenceFilter < HTML::Pipeline::Filter + include RequestStoreReferenceCache + include OutputSafety + + class << self + attr_accessor :reference_type + + def call(doc, context = nil, result = nil) + new(doc, context, result).call_and_update_nodes + end + end + + def initialize(doc, context = nil, result = nil) + super + + @new_nodes = {} + @nodes = self.result[:reference_filter_nodes] + end + + def call_and_update_nodes + with_update_nodes { call } + end + + # Returns a data attribute String to attach to a reference link + # + # attributes - Hash, where the key becomes the data attribute name and the + # value is the data attribute value + # + # Examples: + # + # data_attribute(project: 1, issue: 2) + # # => "data-reference-type=\"SomeReferenceFilter\" data-project=\"1\" data-issue=\"2\"" + # + # data_attribute(project: 3, merge_request: 4) + # # => "data-reference-type=\"SomeReferenceFilter\" data-project=\"3\" data-merge-request=\"4\"" + # + # Returns a String + def data_attribute(attributes = {}) + attributes = attributes.reject { |_, v| v.nil? } + + attributes[:reference_type] ||= self.class.reference_type + attributes[:container] ||= 'body' + attributes[:placement] ||= 'top' + attributes.delete(:original) if context[:no_original_data] + attributes.map do |key, value| + %Q(data-#{key.to_s.dasherize}="#{escape_once(value)}") + end.join(' ') + end + + def ignore_ancestor_query + @ignore_ancestor_query ||= begin + parents = %w(pre code a style) + parents << 'blockquote' if context[:ignore_blockquotes] + + parents.map { |n| "ancestor::#{n}" }.join(' or ') + end + end + + def project + context[:project] + end + + def group + context[:group] + end + + def user + context[:user] + end + + def skip_project_check? + context[:skip_project_check] + end + + def reference_class(type, tooltip: true) + gfm_klass = "gfm gfm-#{type}" + + return gfm_klass unless tooltip + + "#{gfm_klass} has-tooltip" + 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 unless skip_project_check? + end + + # Iterates over all <a> and text() nodes in a document. + # + # Nodes are skipped whenever their ancestor is one of the nodes returned + # by `ignore_ancestor_query`. Link tags are not processed if they have a + # "gfm" class or the "href" attribute is empty. + def each_node + return to_enum(__method__) unless block_given? + + doc.xpath(query).each do |node| + yield node + end + end + + # Returns an Array containing all HTML nodes. + def nodes + @nodes ||= each_node.to_a + end + + # Yields the link's URL and inner HTML whenever the node is a valid <a> tag. + def yield_valid_link(node) + link = unescape_link(node.attr('href').to_s) + inner_html = node.inner_html + + return unless link.force_encoding('UTF-8').valid_encoding? + + yield link, inner_html + end + + def unescape_link(href) + CGI.unescape(href) + end + + def replace_text_when_pattern_matches(node, index, pattern) + return unless node.text =~ pattern + + content = node.to_html + html = yield content + + replace_text_with_html(node, index, html) unless html == content + end + + def replace_link_node_with_text(node, index) + html = yield + + replace_text_with_html(node, index, html) unless html == node.text + end + + def replace_link_node_with_href(node, index, link) + html = yield + + replace_text_with_html(node, index, html) unless html == link + end + + def text_node?(node) + node.is_a?(Nokogiri::XML::Text) + end + + def element_node?(node) + node.is_a?(Nokogiri::XML::Element) + end + + private + + def query + @query ||= %Q{descendant-or-self::text()[not(#{ignore_ancestor_query})] + | descendant-or-self::a[ + not(contains(concat(" ", @class, " "), " gfm ")) and not(@href = "") + ]} + end + + def replace_text_with_html(node, index, html) + replace_and_update_new_nodes(node, index, html) + end + + def replace_and_update_new_nodes(node, index, html) + previous_node = node.previous + next_node = node.next + parent_node = node.parent + # Unfortunately node.replace(html) returns re-parented nodes, not the actual replaced nodes in the doc + # We need to find the actual nodes in the doc that were replaced + node.replace(html) + @new_nodes[index] = [] + + # We replaced node with new nodes, so we find first new node. If previous_node is nil, we take first parent child + new_node = previous_node ? previous_node.next : parent_node&.children&.first + + # We iterate from first to last replaced node and store replaced nodes in @new_nodes + while new_node && new_node != next_node + @new_nodes[index] << new_node.xpath(query) + new_node = new_node.next + end + + @new_nodes[index].flatten! + end + + def only_path? + context[:only_path] + end + + def with_update_nodes + @new_nodes = {} + yield.tap { update_nodes! } + end + + # Once Filter completes replacing nodes, we update nodes with @new_nodes + def update_nodes! + @new_nodes.sort_by { |index, _new_nodes| -index }.each do |index, new_nodes| + nodes[index, 1] = new_nodes + end + result[:reference_filter_nodes] = nodes + end + end + end + end +end diff --git a/lib/banzai/filter/references/snippet_reference_filter.rb b/lib/banzai/filter/references/snippet_reference_filter.rb new file mode 100644 index 00000000000..bf7e0f78609 --- /dev/null +++ b/lib/banzai/filter/references/snippet_reference_filter.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + # 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 < AbstractReferenceFilter + self.reference_type = :snippet + + def self.object_class + Snippet + end + + def find_object(project, id) + return unless project.is_a?(Project) + + project.snippets.find_by(id: id) + end + + def url_for_object(snippet, project) + h = Gitlab::Routing.url_helpers + h.project_snippet_url(project, snippet, + only_path: context[:only_path]) + end + end + end + end +end diff --git a/lib/banzai/filter/references/user_reference_filter.rb b/lib/banzai/filter/references/user_reference_filter.rb new file mode 100644 index 00000000000..04665973f51 --- /dev/null +++ b/lib/banzai/filter/references/user_reference_filter.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + # HTML filter that replaces user or group references with links. + # + # A special `@all` reference is also supported. + class UserReferenceFilter < ReferenceFilter + self.reference_type = :user + + # 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.reference_pattern) do |match| + yield match, $~[:user] + end + end + + def call + return doc if project.nil? && group.nil? && !skip_project_check? + + ref_pattern = User.reference_pattern + ref_pattern_start = /\A#{ref_pattern}\z/ + + nodes.each_with_index do |node, index| + if text_node?(node) + replace_text_when_pattern_matches(node, index, ref_pattern) do |content| + user_link_filter(content) + end + elsif element_node?(node) + yield_valid_link(node) do |link, inner_html| + if link =~ ref_pattern_start + replace_link_node_with_href(node, index, link) do + user_link_filter(link, link_content: inner_html) + end + end + end + end + end + + doc + end + + # Replace `@user` user references in text with links to the referenced + # user's profile page. + # + # text - String text to replace references in. + # link_content - Original content of the link being replaced. + # + # 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, link_content: nil) + self.class.references_in(text) do |match, username| + if username == 'all' && !skip_project_check? + link_to_all(link_content: link_content) + else + cached_call(:banzai_url_for_object, match, path: [User, username.downcase]) do + if namespace = namespaces[username.downcase] + link_to_namespace(namespace, link_content: link_content) || match + else + match + end + end + end + end + end + + # Returns a Hash containing all Namespace objects for the username + # references in the current document. + # + # The keys of this Hash are the namespace paths, the values the + # corresponding Namespace objects. + def namespaces + @namespaces ||= Namespace.eager_load(:owner, :route) + .where_full_path_in(usernames) + .index_by(&:full_path) + .transform_keys(&:downcase) + end + + # Returns all usernames referenced in the current document. + def usernames + refs = Set.new + + nodes.each do |node| + node.to_html.scan(User.reference_pattern) do + refs << $~[:user] + end + end + + refs.to_a + end + + private + + def urls + Gitlab::Routing.url_helpers + end + + def link_class + [reference_class(:project_member, tooltip: false), "js-user-link"].join(" ") + end + + def link_to_all(link_content: nil) + author = context[:author] + + if author && !team_member?(author) + link_content + else + parent_url(link_content, author) + end + end + + def link_to_namespace(namespace, link_content: nil) + if namespace.is_a?(Group) + link_to_group(namespace.full_path, namespace, link_content: link_content) + else + link_to_user(namespace.path, namespace, link_content: link_content) + end + end + + def link_to_group(group, namespace, link_content: nil) + url = urls.group_url(group, only_path: context[:only_path]) + data = data_attribute(group: namespace.id) + content = link_content || Group.reference_prefix + group + + link_tag(url, data, content, namespace.full_name) + end + + def link_to_user(user, namespace, link_content: nil) + url = urls.user_url(user, only_path: context[:only_path]) + data = data_attribute(user: namespace.owner_id) + content = link_content || User.reference_prefix + user + + link_tag(url, data, content, namespace.owner_name) + end + + def link_tag(url, data, link_content, title) + %(<a href="#{url}" #{data} class="#{link_class}" title="#{escape_once(title)}">#{link_content}</a>) + end + + def parent + context[:project] || context[:group] + end + + def parent_group? + parent.is_a?(Group) + end + + def team_member?(user) + if parent_group? + parent.member?(user) + else + parent.team.member?(user) + end + end + + def parent_url(link_content, author) + if parent_group? + url = urls.group_url(parent, only_path: context[:only_path]) + data = data_attribute(group: group.id, author: author.try(:id)) + else + url = urls.project_url(parent, only_path: context[:only_path]) + data = data_attribute(project: project.id, author: author.try(:id)) + end + + content = link_content || User.reference_prefix + 'all' + link_tag(url, data, content, 'All Project and Group Members') + end + end + end + end +end diff --git a/lib/banzai/filter/references/vulnerability_reference_filter.rb b/lib/banzai/filter/references/vulnerability_reference_filter.rb new file mode 100644 index 00000000000..e5f2408eda4 --- /dev/null +++ b/lib/banzai/filter/references/vulnerability_reference_filter.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Banzai + module Filter + module References + # The actual filter is implemented in the EE mixin + class VulnerabilityReferenceFilter < IssuableReferenceFilter + self.reference_type = :vulnerability + + def self.object_class + Vulnerability + end + + private + + def project + context[:project] + end + end + end + end +end + +Banzai::Filter::References::VulnerabilityReferenceFilter.prepend_if_ee('EE::Banzai::Filter::References::VulnerabilityReferenceFilter') diff --git a/lib/banzai/filter/repository_link_filter.rb b/lib/banzai/filter/repository_link_filter.rb index 66b9aac3e7e..04bbcabd93f 100644 --- a/lib/banzai/filter/repository_link_filter.rb +++ b/lib/banzai/filter/repository_link_filter.rb @@ -60,7 +60,7 @@ module Banzai def get_uri_types(paths) return {} if paths.empty? - uri_types = Hash[paths.collect { |name| [name, nil] }] + uri_types = paths.to_h { |name| [name, nil] } get_blob_types(paths).each do |name, type| if type == :blob diff --git a/lib/banzai/filter/snippet_reference_filter.rb b/lib/banzai/filter/snippet_reference_filter.rb deleted file mode 100644 index f4b6edb6174..00000000000 --- a/lib/banzai/filter/snippet_reference_filter.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # 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 < AbstractReferenceFilter - self.reference_type = :snippet - - def self.object_class - Snippet - end - - def find_object(project, id) - return unless project.is_a?(Project) - - project.snippets.find_by(id: id) - end - - def url_for_object(snippet, project) - h = Gitlab::Routing.url_helpers - h.project_snippet_url(project, snippet, - only_path: context[:only_path]) - end - end - end -end diff --git a/lib/banzai/filter/spaced_link_filter.rb b/lib/banzai/filter/spaced_link_filter.rb index f52ffe117d9..ca26e6d1581 100644 --- a/lib/banzai/filter/spaced_link_filter.rb +++ b/lib/banzai/filter/spaced_link_filter.rb @@ -42,7 +42,7 @@ module Banzai TEXT_QUERY = %Q(descendant-or-self::text()[ not(#{IGNORE_PARENTS.map { |p| "ancestor::#{p}" }.join(' or ')}) and contains(., ']\(') - ]).freeze + ]) def call doc.xpath(TEXT_QUERY).each do |node| diff --git a/lib/banzai/filter/suggestion_filter.rb b/lib/banzai/filter/suggestion_filter.rb index ae093580001..56a14ec0737 100644 --- a/lib/banzai/filter/suggestion_filter.rb +++ b/lib/banzai/filter/suggestion_filter.rb @@ -10,7 +10,7 @@ module Banzai def call return doc unless suggestions_filter_enabled? - doc.search('pre.suggestion > code').each do |node| + doc.search('pre.language-suggestion > code').each do |node| node.add_class(TAG_CLASS) end diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb index 1d3bbe43344..731a2bb4c77 100644 --- a/lib/banzai/filter/syntax_highlight_filter.rb +++ b/lib/banzai/filter/syntax_highlight_filter.rb @@ -37,7 +37,7 @@ module Banzai begin code = Rouge::Formatters::HTMLGitlab.format(lex(lexer, node.text), tag: language) - css_classes << " #{language}" if language + css_classes << " language-#{language}" if language rescue # Gracefully handle syntax highlighter bugs/errors to ensure users can # still access an issue/comment/etc. First, retry with the plain text diff --git a/lib/banzai/filter/user_reference_filter.rb b/lib/banzai/filter/user_reference_filter.rb deleted file mode 100644 index 262385524f4..00000000000 --- a/lib/banzai/filter/user_reference_filter.rb +++ /dev/null @@ -1,180 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # HTML filter that replaces user or group references with links. - # - # A special `@all` reference is also supported. - class UserReferenceFilter < ReferenceFilter - self.reference_type = :user - - # 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.reference_pattern) do |match| - yield match, $~[:user] - end - end - - def call - return doc if project.nil? && group.nil? && !skip_project_check? - - ref_pattern = User.reference_pattern - ref_pattern_start = /\A#{ref_pattern}\z/ - - nodes.each_with_index do |node, index| - if text_node?(node) - replace_text_when_pattern_matches(node, index, ref_pattern) do |content| - user_link_filter(content) - end - elsif element_node?(node) - yield_valid_link(node) do |link, inner_html| - if link =~ ref_pattern_start - replace_link_node_with_href(node, index, link) do - user_link_filter(link, link_content: inner_html) - end - end - end - end - end - - doc - end - - # Replace `@user` user references in text with links to the referenced - # user's profile page. - # - # text - String text to replace references in. - # link_content - Original content of the link being replaced. - # - # 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, link_content: nil) - self.class.references_in(text) do |match, username| - if username == 'all' && !skip_project_check? - link_to_all(link_content: link_content) - else - cached_call(:banzai_url_for_object, match, path: [User, username.downcase]) do - if namespace = namespaces[username.downcase] - link_to_namespace(namespace, link_content: link_content) || match - else - match - end - end - end - end - end - - # Returns a Hash containing all Namespace objects for the username - # references in the current document. - # - # The keys of this Hash are the namespace paths, the values the - # corresponding Namespace objects. - def namespaces - @namespaces ||= Namespace.eager_load(:owner, :route) - .where_full_path_in(usernames) - .index_by(&:full_path) - .transform_keys(&:downcase) - end - - # Returns all usernames referenced in the current document. - def usernames - refs = Set.new - - nodes.each do |node| - node.to_html.scan(User.reference_pattern) do - refs << $~[:user] - end - end - - refs.to_a - end - - private - - def urls - Gitlab::Routing.url_helpers - end - - def link_class - [reference_class(:project_member, tooltip: false), "js-user-link"].join(" ") - end - - def link_to_all(link_content: nil) - author = context[:author] - - if author && !team_member?(author) - link_content - else - parent_url(link_content, author) - end - end - - def link_to_namespace(namespace, link_content: nil) - if namespace.is_a?(Group) - link_to_group(namespace.full_path, namespace, link_content: link_content) - else - link_to_user(namespace.path, namespace, link_content: link_content) - end - end - - def link_to_group(group, namespace, link_content: nil) - url = urls.group_url(group, only_path: context[:only_path]) - data = data_attribute(group: namespace.id) - content = link_content || Group.reference_prefix + group - - link_tag(url, data, content, namespace.full_name) - end - - def link_to_user(user, namespace, link_content: nil) - url = urls.user_url(user, only_path: context[:only_path]) - data = data_attribute(user: namespace.owner_id) - content = link_content || User.reference_prefix + user - - link_tag(url, data, content, namespace.owner_name) - end - - def link_tag(url, data, link_content, title) - %(<a href="#{url}" #{data} class="#{link_class}" title="#{escape_once(title)}">#{link_content}</a>) - end - - def parent - context[:project] || context[:group] - end - - def parent_group? - parent.is_a?(Group) - end - - def team_member?(user) - if parent_group? - parent.member?(user) - else - parent.team.member?(user) - end - end - - def parent_url(link_content, author) - if parent_group? - url = urls.group_url(parent, only_path: context[:only_path]) - data = data_attribute(group: group.id, author: author.try(:id)) - else - url = urls.project_url(parent, only_path: context[:only_path]) - data = data_attribute(project: project.id, author: author.try(:id)) - end - - content = link_content || User.reference_prefix + 'all' - link_tag(url, data, content, 'All Project and Group Members') - end - end - end -end diff --git a/lib/banzai/filter/vulnerability_reference_filter.rb b/lib/banzai/filter/vulnerability_reference_filter.rb deleted file mode 100644 index a59e9836d69..00000000000 --- a/lib/banzai/filter/vulnerability_reference_filter.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Banzai - module Filter - # The actual filter is implemented in the EE mixin - class VulnerabilityReferenceFilter < IssuableReferenceFilter - self.reference_type = :vulnerability - - def self.object_class - Vulnerability - end - - private - - def project - context[:project] - end - end - end -end - -Banzai::Filter::VulnerabilityReferenceFilter.prepend_if_ee('EE::Banzai::Filter::VulnerabilityReferenceFilter') diff --git a/lib/banzai/filter/wiki_link_filter/rewriter.rb b/lib/banzai/filter/wiki_link_filter/rewriter.rb index f4cc8beeb52..b4c2e7efae3 100644 --- a/lib/banzai/filter/wiki_link_filter/rewriter.rb +++ b/lib/banzai/filter/wiki_link_filter/rewriter.rb @@ -6,7 +6,7 @@ module Banzai class Rewriter def initialize(link_string, wiki:, slug:) @uri = Addressable::URI.parse(link_string) - @wiki_base_path = wiki && wiki.wiki_base_path + @wiki_base_path = wiki&.wiki_base_path @slug = slug end @@ -41,7 +41,8 @@ module Banzai # Any link _not_ of the form `http://example.com/` def apply_relative_link_rules! if @uri.relative? && @uri.path.present? - link = ::File.join(@wiki_base_path, @uri.path) + link = @uri.path + link = ::File.join(@wiki_base_path, link) unless link.starts_with?(@wiki_base_path) link = "#{link}##{@uri.fragment}" if @uri.fragment @uri = Addressable::URI.parse(link) end diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb index e5ec0a0a006..028e3c44dc3 100644 --- a/lib/banzai/pipeline/gfm_pipeline.rb +++ b/lib/banzai/pipeline/gfm_pipeline.rb @@ -51,19 +51,19 @@ module Banzai def self.reference_filters [ - Filter::UserReferenceFilter, - Filter::ProjectReferenceFilter, - Filter::DesignReferenceFilter, - Filter::IssueReferenceFilter, - Filter::ExternalIssueReferenceFilter, - Filter::MergeRequestReferenceFilter, - Filter::SnippetReferenceFilter, - Filter::CommitRangeReferenceFilter, - Filter::CommitReferenceFilter, - Filter::LabelReferenceFilter, - Filter::MilestoneReferenceFilter, - Filter::AlertReferenceFilter, - Filter::FeatureFlagReferenceFilter + Filter::References::UserReferenceFilter, + Filter::References::ProjectReferenceFilter, + Filter::References::DesignReferenceFilter, + Filter::References::IssueReferenceFilter, + Filter::References::ExternalIssueReferenceFilter, + Filter::References::MergeRequestReferenceFilter, + Filter::References::SnippetReferenceFilter, + Filter::References::CommitRangeReferenceFilter, + Filter::References::CommitReferenceFilter, + Filter::References::LabelReferenceFilter, + Filter::References::MilestoneReferenceFilter, + Filter::References::AlertReferenceFilter, + Filter::References::FeatureFlagReferenceFilter ] end diff --git a/lib/banzai/pipeline/label_pipeline.rb b/lib/banzai/pipeline/label_pipeline.rb index 725cccc4b2b..ccfda2052e6 100644 --- a/lib/banzai/pipeline/label_pipeline.rb +++ b/lib/banzai/pipeline/label_pipeline.rb @@ -6,7 +6,7 @@ module Banzai def self.filters @filters ||= FilterArray[ Filter::SanitizationFilter, - Filter::LabelReferenceFilter + Filter::References::LabelReferenceFilter ] end end diff --git a/lib/banzai/pipeline/single_line_pipeline.rb b/lib/banzai/pipeline/single_line_pipeline.rb index 4bf98099662..65a5e28b704 100644 --- a/lib/banzai/pipeline/single_line_pipeline.rb +++ b/lib/banzai/pipeline/single_line_pipeline.rb @@ -17,15 +17,15 @@ module Banzai def self.reference_filters [ - Filter::UserReferenceFilter, - Filter::IssueReferenceFilter, - Filter::ExternalIssueReferenceFilter, - Filter::MergeRequestReferenceFilter, - Filter::SnippetReferenceFilter, - Filter::CommitRangeReferenceFilter, - Filter::CommitReferenceFilter, - Filter::AlertReferenceFilter, - Filter::FeatureFlagReferenceFilter + Filter::References::UserReferenceFilter, + Filter::References::IssueReferenceFilter, + Filter::References::ExternalIssueReferenceFilter, + Filter::References::MergeRequestReferenceFilter, + Filter::References::SnippetReferenceFilter, + Filter::References::CommitRangeReferenceFilter, + Filter::References::CommitReferenceFilter, + Filter::References::AlertReferenceFilter, + Filter::References::FeatureFlagReferenceFilter ] end diff --git a/lib/banzai/pipeline/wiki_pipeline.rb b/lib/banzai/pipeline/wiki_pipeline.rb index 97a03895ff3..caba9570ab9 100644 --- a/lib/banzai/pipeline/wiki_pipeline.rb +++ b/lib/banzai/pipeline/wiki_pipeline.rb @@ -5,7 +5,7 @@ module Banzai class WikiPipeline < FullPipeline def self.filters @filters ||= begin - super.insert_after(Filter::TableOfContentsFilter, Filter::GollumTagsFilter) + super.insert_before(Filter::ImageLazyLoadFilter, Filter::GollumTagsFilter) .insert_before(Filter::TaskListFilter, Filter::WikiLinkFilter) end end |