Commit ddb94691 authored by Alex Kalderimis's avatar Alex Kalderimis

Merge branch '327630-refactor-onto-abstract-reference-filter' into 'master'

Refactor Banzai reference filters into new subdirectory

See merge request gitlab-org/gitlab!59300
parents 37c6d69c ae766653
...@@ -1363,8 +1363,8 @@ RSpec/AnyInstanceOf: ...@@ -1363,8 +1363,8 @@ RSpec/AnyInstanceOf:
- 'spec/lib/backup/files_spec.rb' - 'spec/lib/backup/files_spec.rb'
- 'spec/lib/backup/manager_spec.rb' - 'spec/lib/backup/manager_spec.rb'
- 'spec/lib/banzai/commit_renderer_spec.rb' - 'spec/lib/banzai/commit_renderer_spec.rb'
- 'spec/lib/banzai/filter/external_issue_reference_filter_spec.rb' - 'spec/lib/banzai/filter/references/external_issue_reference_filter_spec.rb'
- 'spec/lib/banzai/filter/issue_reference_filter_spec.rb' - 'spec/lib/banzai/filter/references/issue_reference_filter_spec.rb'
- 'spec/lib/banzai/filter/repository_link_filter_spec.rb' - 'spec/lib/banzai/filter/repository_link_filter_spec.rb'
- 'spec/lib/banzai/pipeline/gfm_pipeline_spec.rb' - 'spec/lib/banzai/pipeline/gfm_pipeline_spec.rb'
- 'spec/lib/extracts_ref_spec.rb' - 'spec/lib/extracts_ref_spec.rb'
......
# frozen_string_literal: true
module EE
module Banzai
module Filter
module AbstractReferenceFilter
extend ::Gitlab::Utils::Override
override :current_project_namespace_path
def current_project_namespace_path
@current_project_namespace_path ||= (project&.namespace || group)&.full_path
end
end
end
end
end
# frozen_string_literal: true
module EE
module Banzai
module Filter
# HTML filter that replaces epic references with links. References to
# epics that do not exist are ignored.
#
# This filter supports cross-project/group references.
module EpicReferenceFilter
extend ActiveSupport::Concern
class_methods do
def references_in(text, pattern = object_class.reference_pattern)
text.gsub(pattern) do |match|
symbol = $~[object_sym]
if object_class.reference_valid?(symbol)
yield match, symbol.to_i, nil, $~[:group], $~
else
match
end
end
end
end
def url_for_object(epic, group)
urls = ::Gitlab::Routing.url_helpers
urls.group_epic_url(group, epic, only_path: context[:only_path])
end
def data_attributes_for(text, group, object, link_content: false, link_reference: false)
{
original: escape_html_entities(text),
link: link_content,
link_reference: link_reference,
group: group.id,
object_sym => object.id
}
end
# rubocop: disable CodeReuse/ActiveRecord
def parent_records(parent, ids)
parent.epics.where(iid: ids.to_a)
end
# rubocop: enable CodeReuse/ActiveRecord
private
def parent_type
:group
end
end
end
end
end
# frozen_string_literal: true
module EE
module Banzai
module Filter
module LabelReferenceFilter
extend ::Gitlab::Utils::Override
override :data_attributes_for
def data_attributes_for(text, parent, object, link_content: false, link_reference: false)
return super unless object.scoped_label?
# Enabling HTML tooltips for scoped labels here and additional escaping is done in `object_link_title`
super.merge!(
html: true
)
end
override :object_link_title
def object_link_title(object, matches)
return super unless object.scoped_label?
ERB::Util.html_escape(super)
end
end
end
end
end
# frozen_string_literal: true
module EE
module Banzai
module Filter
module References
module AbstractReferenceFilter
extend ::Gitlab::Utils::Override
override :current_project_namespace_path
def current_project_namespace_path
@current_project_namespace_path ||= (project&.namespace || group)&.full_path
end
end
end
end
end
end
# frozen_string_literal: true
module EE
module Banzai
module Filter
module References
# HTML filter that replaces epic references with links. References to
# epics that do not exist are ignored.
#
# This filter supports cross-project/group references.
module EpicReferenceFilter
extend ActiveSupport::Concern
class_methods do
def references_in(text, pattern = object_class.reference_pattern)
text.gsub(pattern) do |match|
symbol = $~[object_sym]
if object_class.reference_valid?(symbol)
yield match, symbol.to_i, nil, $~[:group], $~
else
match
end
end
end
end
def url_for_object(epic, group)
urls = ::Gitlab::Routing.url_helpers
urls.group_epic_url(group, epic, only_path: context[:only_path])
end
def data_attributes_for(text, group, object, link_content: false, link_reference: false)
{
original: escape_html_entities(text),
link: link_content,
link_reference: link_reference,
group: group.id,
object_sym => object.id
}
end
# rubocop: disable CodeReuse/ActiveRecord
def parent_records(parent, ids)
parent.epics.where(iid: ids.to_a)
end
# rubocop: enable CodeReuse/ActiveRecord
private
def parent_type
:group
end
end
end
end
end
end
# frozen_string_literal: true
module EE
module Banzai
module Filter
module References
# HTML filter that replaces iteration references with links.
module IterationReferenceFilter
include ::Gitlab::Utils::StrongMemoize
def find_object(parent, id)
return unless valid_context?(parent)
find_iteration(parent, id: id)
end
def valid_context?(parent)
group_context?(parent) || project_context?(parent)
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 = ::Iteration.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 != ::Iteration.reference_pattern
iterations = {}
unescaped_html = unescape_html_entities(text).gsub(pattern) do |match|
iteration = parse_and_find_iteration($~[:project], $~[:namespace], $~[:iteration_id], $~[:iteration_name])
if iteration
iterations[iteration.id] = yield match, iteration.id, $~[:project], $~[:namespace], $~
"#{::Banzai::Filter::References::AbstractReferenceFilter::REFERENCE_PLACEHOLDER}#{iteration.id}"
else
match
end
end
return text if iterations.empty?
escape_with_placeholders(unescaped_html, iterations)
end
def parse_and_find_iteration(project_ref, namespace_ref, iteration_id, iteration_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
iteration_params = iteration_params(iteration_id, iteration_name)
find_iteration(parent, iteration_params)
end
def iteration_params(id, name)
if name
{ name: name.tr('"', '') }
else
{ id: id.to_i }
end
end
# rubocop: disable CodeReuse/ActiveRecord
def find_iteration(parent, params)
::Iteration.for_projects_and_groups(project_ids(parent), group_and_ancestors_ids(parent)).find_by(**params)
end
# rubocop: enable CodeReuse/ActiveRecord
def project_ids(parent)
parent.id if project_context?(parent)
end
def group_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(iteration, _parent)
::Gitlab::Routing
.url_helpers
.iteration_url(iteration, only_path: context[:only_path])
end
def object_link_text(object, matches)
iteration_link = escape_once(super)
reference = object.project&.to_reference_base(project)
if reference.present?
"#{iteration_link} <i>in #{reference}</i>".html_safe
else
iteration_link
end
end
def object_link_title(_object, _matches)
'Iteration'
end
end
end
end
end
end
# frozen_string_literal: true
module EE
module Banzai
module Filter
module References
module LabelReferenceFilter
extend ::Gitlab::Utils::Override
override :data_attributes_for
def data_attributes_for(text, parent, object, link_content: false, link_reference: false)
return super unless object.scoped_label?
# Enabling HTML tooltips for scoped labels here and additional escaping is done in `object_link_title`
super.merge!(
html: true
)
end
override :object_link_title
def object_link_title(object, matches)
return super unless object.scoped_label?
ERB::Util.html_escape(super)
end
end
end
end
end
end
# frozen_string_literal: true
module EE
module Banzai
module Filter
module References
# HTML filter that replaces vulnerability references with links. References to
# vulnerabilities that do not exist are ignored.
#
# This filter supports cross-project/group references.
module VulnerabilityReferenceFilter
extend ActiveSupport::Concern
class_methods do
def references_in(text, pattern = object_class.reference_pattern)
text.gsub(pattern) do |match|
symbol = $~[object_sym]
if object_class.reference_valid?(symbol)
yield match, symbol.to_i, $~[:project], $~[:namespace], $~
else
match
end
end
end
end
def unescape_link(href)
return href if href =~ object_class.reference_pattern
super
end
def url_for_object(vulnerability, project)
urls = ::Gitlab::Routing.url_helpers
urls.project_security_vulnerability_url(project, vulnerability, only_path: context[:only_path])
end
def data_attributes_for(text, project, object, link_content: false, link_reference: false)
{
original: escape_html_entities(text),
link: link_content,
link_reference: link_reference,
project: project.id,
object_sym => object.id
}
end
def parent_records(parent, ids)
return ::Vulnerability.none if ids.blank? || parent.nil?
parent.vulnerabilities.id_in(ids.to_a)
end
def record_identifier(record)
record.id.to_i
end
private
def parent_type
:project
end
end
end
end
end
end
# frozen_string_literal: true
module EE
module Banzai
module Filter
# HTML filter that replaces vulnerability references with links. References to
# vulnerabilities that do not exist are ignored.
#
# This filter supports cross-project/group references.
module VulnerabilityReferenceFilter
extend ActiveSupport::Concern
class_methods do
def references_in(text, pattern = object_class.reference_pattern)
text.gsub(pattern) do |match|
symbol = $~[object_sym]
if object_class.reference_valid?(symbol)
yield match, symbol.to_i, $~[:project], $~[:namespace], $~
else
match
end
end
end
end
def unescape_link(href)
return href if href =~ object_class.reference_pattern
super
end
def url_for_object(vulnerability, project)
urls = ::Gitlab::Routing.url_helpers
urls.project_security_vulnerability_url(project, vulnerability, only_path: context[:only_path])
end
def data_attributes_for(text, project, object, link_content: false, link_reference: false)
{
original: escape_html_entities(text),
link: link_content,
link_reference: link_reference,
project: project.id,
object_sym => object.id
}
end
def parent_records(parent, ids)
return ::Vulnerability.none if ids.blank? || parent.nil?
parent.vulnerabilities.id_in(ids.to_a)
end
def record_identifier(record)
record.id.to_i
end
private
def parent_type
:project
end
end
end
end
end
...@@ -16,9 +16,9 @@ module EE ...@@ -16,9 +16,9 @@ module EE
def reference_filters def reference_filters
[ [
::Banzai::Filter::EpicReferenceFilter, ::Banzai::Filter::References::EpicReferenceFilter,
::Banzai::Filter::IterationReferenceFilter, ::Banzai::Filter::References::IterationReferenceFilter,
::Banzai::Filter::VulnerabilityReferenceFilter, ::Banzai::Filter::References::VulnerabilityReferenceFilter,
*super *super
] ]
end end
......
...@@ -9,9 +9,9 @@ module EE ...@@ -9,9 +9,9 @@ module EE
class_methods do class_methods do
def reference_filters def reference_filters
[ [
::Banzai::Filter::EpicReferenceFilter, ::Banzai::Filter::References::EpicReferenceFilter,
::Banzai::Filter::IterationReferenceFilter, ::Banzai::Filter::References::IterationReferenceFilter,
::Banzai::Filter::VulnerabilityReferenceFilter, ::Banzai::Filter::References::VulnerabilityReferenceFilter,
*super *super
] ]
end end
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::EpicReferenceFilter do RSpec.describe Banzai::Filter::References::EpicReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
let(:urls) { Gitlab::Routing.url_helpers } let(:urls) { Gitlab::Routing.url_helpers }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::IterationReferenceFilter do RSpec.describe Banzai::Filter::References::IterationReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
let(:parent_group) { create(:group, :public) } let(:parent_group) { create(:group, :public) }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::LabelReferenceFilter do RSpec.describe Banzai::Filter::References::LabelReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
let(:project) { create(:project, :public, name: 'sample-project') } let(:project) { create(:project, :public, name: 'sample-project') }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::VulnerabilityReferenceFilter do RSpec.describe Banzai::Filter::References::VulnerabilityReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
let(:urls) { Gitlab::Routing.url_helpers } let(:urls) { Gitlab::Routing.url_helpers }
......
This diff is collapsed.
# 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
# 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
# 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
# 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
# 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')
# 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
# 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
# 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
# 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
# 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')
# 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')
# 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
# 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
# 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
# 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
This diff is collapsed.
# 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
# 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
# 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
# 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
# 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')
# 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
# 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
# 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
# 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
# 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')
# 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, 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
end
Banzai::Filter::References::LabelReferenceFilter.prepend_if_ee('EE::Banzai::Filter::References::LabelReferenceFilter')
# 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
# frozen_string_literal: true # frozen_string_literal: true
module EE module Banzai
module Banzai module Filter
module Filter module References
# HTML filter that replaces iteration references with links. # HTML filter that replaces milestone references with links.
module IterationReferenceFilter class MilestoneReferenceFilter < AbstractReferenceFilter
include ::Gitlab::Utils::StrongMemoize 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) def find_object(parent, id)
return unless valid_context?(parent) return unless valid_context?(parent)
find_iteration(parent, id: id) 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 end
def valid_context?(parent) def valid_context?(parent)
group_context?(parent) || project_context?(parent) strong_memoize(:valid_context) do
group_context?(parent) || project_context?(parent)
end
end end
def group_context?(parent) def group_context?(parent)
...@@ -29,30 +46,30 @@ module EE ...@@ -29,30 +46,30 @@ module EE
end end
end end
def references_in(text, pattern = ::Iteration.reference_pattern) def references_in(text, pattern = Milestone.reference_pattern)
# We'll handle here the references that follow the `reference_pattern`. # We'll handle here the references that follow the `reference_pattern`.
# Other patterns (for example, the link pattern) are handled by the # Other patterns (for example, the link pattern) are handled by the
# default implementation. # default implementation.
return super(text, pattern) if pattern != ::Iteration.reference_pattern return super(text, pattern) if pattern != Milestone.reference_pattern
iterations = {} milestones = {}
unescaped_html = unescape_html_entities(text).gsub(pattern) do |match| unescaped_html = unescape_html_entities(text).gsub(pattern) do |match|
iteration = parse_and_find_iteration($~[:project], $~[:namespace], $~[:iteration_id], $~[:iteration_name]) milestone = find_milestone($~[:project], $~[:namespace], $~[:milestone_iid], $~[:milestone_name])
if iteration if milestone
iterations[iteration.id] = yield match, iteration.id, $~[:project], $~[:namespace], $~ milestones[milestone.id] = yield match, milestone.id, $~[:project], $~[:namespace], $~
"#{::Banzai::Filter::AbstractReferenceFilter::REFERENCE_PLACEHOLDER}#{iteration.id}" "#{REFERENCE_PLACEHOLDER}#{milestone.id}"
else else
match match
end end
end end
return text if iterations.empty? return text if milestones.empty?
escape_with_placeholders(unescaped_html, iterations) escape_with_placeholders(unescaped_html, milestones)
end end
def parse_and_find_iteration(project_ref, namespace_ref, iteration_id, iteration_name) def find_milestone(project_ref, namespace_ref, milestone_id, milestone_name)
project_path = full_project_path(namespace_ref, project_ref) project_path = full_project_path(namespace_ref, project_ref)
# Returns group if project is not found by path # Returns group if project is not found by path
...@@ -60,30 +77,36 @@ module EE ...@@ -60,30 +77,36 @@ module EE
return unless parent return unless parent
iteration_params = iteration_params(iteration_id, iteration_name) milestone_params = milestone_params(milestone_id, milestone_name)
find_iteration(parent, iteration_params) find_milestone_with_finder(parent, milestone_params)
end end
def iteration_params(id, name) def milestone_params(iid, name)
if name if name
{ name: name.tr('"', '') } { name: name.tr('"', '') }
else else
{ id: id.to_i } { iid: iid.to_i }
end end
end end
# rubocop: disable CodeReuse/ActiveRecord def find_milestone_with_finder(parent, params)
def find_iteration(parent, params) finder_params = milestone_finder_params(parent, params[:iid].present?)
::Iteration.for_projects_and_groups(project_ids(parent), group_and_ancestors_ids(parent)).find_by(**params)
MilestonesFinder.new(finder_params).find_by(params)
end end
# rubocop: enable CodeReuse/ActiveRecord
def project_ids(parent) def milestone_finder_params(parent, find_by_iid)
parent.id if project_context?(parent) { 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 end
def group_and_ancestors_ids(parent) def self_and_ancestors_ids(parent)
if group_context?(parent) if group_context?(parent)
parent.self_and_ancestors.select(:id) parent.self_and_ancestors.select(:id)
elsif project_context?(parent) elsif project_context?(parent)
...@@ -91,25 +114,25 @@ module EE ...@@ -91,25 +114,25 @@ module EE
end end
end end
def url_for_object(iteration, _parent) def url_for_object(milestone, project)
::Gitlab::Routing Gitlab::Routing
.url_helpers .url_helpers
.iteration_url(iteration, only_path: context[:only_path]) .milestone_url(milestone, only_path: context[:only_path])
end end
def object_link_text(object, matches) def object_link_text(object, matches)
iteration_link = escape_once(super) milestone_link = escape_once(super)
reference = object.project&.to_reference_base(project) reference = object.project&.to_reference_base(project)
if reference.present? if reference.present?
"#{iteration_link} <i>in #{reference}</i>".html_safe "#{milestone_link} <i>in #{reference}</i>".html_safe
else else
iteration_link milestone_link
end end
end end
def object_link_title(_object, _matches) def object_link_title(object, matches)
'Iteration' nil
end end
end end
end end
......
# 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
# 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
# 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
# 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
# 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')
# 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
# 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
# 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')
...@@ -51,19 +51,19 @@ module Banzai ...@@ -51,19 +51,19 @@ module Banzai
def self.reference_filters def self.reference_filters
[ [
Filter::UserReferenceFilter, Filter::References::UserReferenceFilter,
Filter::ProjectReferenceFilter, Filter::References::ProjectReferenceFilter,
Filter::DesignReferenceFilter, Filter::References::DesignReferenceFilter,
Filter::IssueReferenceFilter, Filter::References::IssueReferenceFilter,
Filter::ExternalIssueReferenceFilter, Filter::References::ExternalIssueReferenceFilter,
Filter::MergeRequestReferenceFilter, Filter::References::MergeRequestReferenceFilter,
Filter::SnippetReferenceFilter, Filter::References::SnippetReferenceFilter,
Filter::CommitRangeReferenceFilter, Filter::References::CommitRangeReferenceFilter,
Filter::CommitReferenceFilter, Filter::References::CommitReferenceFilter,
Filter::LabelReferenceFilter, Filter::References::LabelReferenceFilter,
Filter::MilestoneReferenceFilter, Filter::References::MilestoneReferenceFilter,
Filter::AlertReferenceFilter, Filter::References::AlertReferenceFilter,
Filter::FeatureFlagReferenceFilter Filter::References::FeatureFlagReferenceFilter
] ]
end end
......
...@@ -6,7 +6,7 @@ module Banzai ...@@ -6,7 +6,7 @@ module Banzai
def self.filters def self.filters
@filters ||= FilterArray[ @filters ||= FilterArray[
Filter::SanitizationFilter, Filter::SanitizationFilter,
Filter::LabelReferenceFilter Filter::References::LabelReferenceFilter
] ]
end end
end end
......
...@@ -17,15 +17,15 @@ module Banzai ...@@ -17,15 +17,15 @@ module Banzai
def self.reference_filters def self.reference_filters
[ [
Filter::UserReferenceFilter, Filter::References::UserReferenceFilter,
Filter::IssueReferenceFilter, Filter::References::IssueReferenceFilter,
Filter::ExternalIssueReferenceFilter, Filter::References::ExternalIssueReferenceFilter,
Filter::MergeRequestReferenceFilter, Filter::References::MergeRequestReferenceFilter,
Filter::SnippetReferenceFilter, Filter::References::SnippetReferenceFilter,
Filter::CommitRangeReferenceFilter, Filter::References::CommitRangeReferenceFilter,
Filter::CommitReferenceFilter, Filter::References::CommitReferenceFilter,
Filter::AlertReferenceFilter, Filter::References::AlertReferenceFilter,
Filter::FeatureFlagReferenceFilter Filter::References::FeatureFlagReferenceFilter
] ]
end end
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::AbstractReferenceFilter do RSpec.describe Banzai::Filter::References::AbstractReferenceFilter do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let(:doc) { Nokogiri::HTML.fragment('') } let(:doc) { Nokogiri::HTML.fragment('') }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::AlertReferenceFilter do RSpec.describe Banzai::Filter::References::AlertReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
let_it_be(:project) { create(:project, :public) } let_it_be(:project) { create(:project, :public) }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::CommitRangeReferenceFilter do RSpec.describe Banzai::Filter::References::CommitRangeReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
let(:project) { create(:project, :public, :repository) } let(:project) { create(:project, :public, :repository) }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::CommitReferenceFilter do RSpec.describe Banzai::Filter::References::CommitReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
let(:project) { create(:project, :public, :repository) } let(:project) { create(:project, :public, :repository) }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::DesignReferenceFilter do RSpec.describe Banzai::Filter::References::DesignReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
include DesignManagementTestHelpers include DesignManagementTestHelpers
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::ExternalIssueReferenceFilter do RSpec.describe Banzai::Filter::References::ExternalIssueReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
let_it_be_with_refind(:project) { create(:project) } let_it_be_with_refind(:project) { create(:project) }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::FeatureFlagReferenceFilter do RSpec.describe Banzai::Filter::References::FeatureFlagReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
let_it_be(:project) { create(:project, :public) } let_it_be(:project) { create(:project, :public) }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::IssueReferenceFilter do RSpec.describe Banzai::Filter::References::IssueReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
include DesignManagementTestHelpers include DesignManagementTestHelpers
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
require 'spec_helper' require 'spec_helper'
require 'html/pipeline' require 'html/pipeline'
RSpec.describe Banzai::Filter::LabelReferenceFilter do RSpec.describe Banzai::Filter::References::LabelReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
let(:project) { create(:project, :public, name: 'sample-project') } let(:project) { create(:project, :public, name: 'sample-project') }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::MergeRequestReferenceFilter do RSpec.describe Banzai::Filter::References::MergeRequestReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
let(:project) { create(:project, :public) } let(:project) { create(:project, :public) }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::MilestoneReferenceFilter do RSpec.describe Banzai::Filter::References::MilestoneReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
let_it_be(:parent_group) { create(:group, :public) } let_it_be(:parent_group) { create(:group, :public) }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::ProjectReferenceFilter do RSpec.describe Banzai::Filter::References::ProjectReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
def invalidate_reference(reference) def invalidate_reference(reference)
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::ReferenceFilter do RSpec.describe Banzai::Filter::References::ReferenceFilter do
let(:project) { build_stubbed(:project) } let(:project) { build_stubbed(:project) }
describe '#each_node' do describe '#each_node' do
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::SnippetReferenceFilter do RSpec.describe Banzai::Filter::References::SnippetReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
let(:project) { create(:project, :public) } let(:project) { create(:project, :public) }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Banzai::Filter::UserReferenceFilter do RSpec.describe Banzai::Filter::References::UserReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
def get_reference(user) def get_reference(user)
......
...@@ -25,7 +25,7 @@ RSpec.describe Banzai::Pipeline::GfmPipeline do ...@@ -25,7 +25,7 @@ RSpec.describe Banzai::Pipeline::GfmPipeline do
issue = create(:issue, project: project) issue = create(:issue, project: project)
markdown = "text #{issue.to_reference(project, full: true)}" markdown = "text #{issue.to_reference(project, full: true)}"
expect_any_instance_of(Banzai::Filter::ReferenceFilter).to receive(:each_node).once expect_any_instance_of(Banzai::Filter::References::ReferenceFilter).to receive(:each_node).once
described_class.call(markdown, project: project) described_class.call(markdown, project: project)
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment