gitlab-org--gitlab-foss/lib/banzai/reference_parser/base_parser.rb

277 lines
8.8 KiB
Ruby

# frozen_string_literal: true
module Banzai
module ReferenceParser
# Base class for reference parsing classes.
#
# Each parser should also specify its reference type by calling
# `self.reference_type = ...` in the body of the class. The value of this
# method should be a symbol such as `:issue` or `:merge_request`. For
# example:
#
# class IssueParser < BaseParser
# self.reference_type = :issue
# end
#
# The reference type is used to determine what nodes to pass to the
# `referenced_by` method.
#
# Parser classes should either implement the instance method
# `references_relation` or overwrite `referenced_by`. The
# `references_relation` method is supposed to return an
# ActiveRecord::Relation used as a base relation for retrieving the objects
# referenced in a set of HTML nodes.
#
# Each class can implement two additional methods:
#
# * `nodes_user_can_reference`: returns an Array of nodes the given user can
# refer to.
# * `nodes_visible_to_user`: returns an Array of nodes that are visible to
# the given user.
#
# You only need to overwrite these methods if you want to tweak who can see
# which references. For example, the IssueParser class defines its own
# `nodes_visible_to_user` method so it can ensure users can only see issues
# they have access to.
class BaseParser
class << self
attr_accessor :reference_type, :reference_options
end
# Returns the attribute name containing the value for every object to be
# parsed by the current parser.
#
# For example, for a parser class that returns "Animal" objects this
# attribute would be "data-animal".
def self.data_attribute
@data_attribute ||= "data-#{reference_type.to_s.dasherize}"
end
# context - An instance of `Banzai::RenderContext`.
def initialize(context)
@context = context
end
def project_for_node(node)
context.project_for_node(node)
end
# Returns all the nodes containing references that the user can refer to.
def nodes_user_can_reference(user, nodes)
nodes
end
# Returns all the nodes that are visible to the given user.
def nodes_visible_to_user(user, nodes)
projects = lazy { projects_for_nodes(nodes) }
project_attr = 'data-project'
preload_associations(projects, user)
nodes.select do |node|
if node.has_attribute?(project_attr)
can_read_reference?(user, projects[node], node)
else
true
end
end
end
# Returns an Array of objects referenced by any of the given HTML nodes.
def referenced_by(nodes, options = {})
ids = unique_attribute_values(nodes, self.class.data_attribute)
return ids if options.fetch(:ids_only, false)
if ids.empty?
references_relation.none
else
references_relation.where(id: ids)
end
end
# Returns the ActiveRecord::Relation to use for querying references in the
# DB.
def references_relation
raise NotImplementedError,
"#{self.class} does not implement #{__method__}"
end
# Returns a Hash containing attribute values per project ID.
#
# The returned Hash uses the following format:
#
# { project id => [value1, value2, ...] }
#
# nodes - An Array of HTML nodes to process.
# attribute - The name of the attribute (as a String) for which to gather
# values.
#
# Returns a Hash.
def gather_attributes_per_project(nodes, attribute)
per_project = Hash.new { |hash, key| hash[key] = Set.new }
nodes.each do |node|
project_id = node.attr('data-project').to_i
id = node.attr(attribute)
per_project[project_id] << id if id
end
per_project
end
# Returns a Hash containing objects for an attribute grouped per the
# nodes that reference them.
#
# The returned Hash uses the following format:
#
# { node => row }
#
# nodes - An Array of HTML nodes to process.
#
# collection - The model or ActiveRecord relation to use for retrieving
# rows from the database.
#
# attribute - The name of the attribute containing the primary key values
# for every row.
#
# Returns a Hash.
def grouped_objects_for_nodes(nodes, collection, attribute)
return {} if nodes.empty?
ids = unique_attribute_values(nodes, attribute)
collection_objects = collection_objects_for_ids(collection, ids)
objects_by_id = collection_objects.index_by(&:id)
nodes.each_with_object({}) do |node, hash|
if node.has_attribute?(attribute)
obj = objects_by_id[node.attr(attribute).to_i]
hash[node] = obj if obj
end
end
end
# Returns an Array containing all unique values of an attribute of the
# given nodes.
def unique_attribute_values(nodes, attribute)
values = Set.new
nodes.each do |node|
if node.has_attribute?(attribute)
values << node.attr(attribute)
end
end
values.to_a
end
# Queries the collection for the objects with the given IDs.
#
# If the RequestStore module is enabled this method will only query any
# objects that have not yet been queried. For objects that have already
# been queried the object is returned from the cache.
def collection_objects_for_ids(collection, ids)
if Gitlab::SafeRequestStore.active?
ids = ids.map(&:to_i).uniq
cache = collection_cache[collection_cache_key(collection)]
to_query = ids - cache.keys
unless to_query.empty?
collection.where(id: to_query).each { |row| cache[row.id] = row }
end
ids.each_with_object([]) do |id, array|
row = cache[id]
array << row if row
end
else
collection.where(id: ids)
end
end
# Returns the cache key to use for a collection.
def collection_cache_key(collection)
collection.respond_to?(:model) ? collection.model : collection
end
# Processes the list of HTML documents and returns an Array containing all
# the references.
def process(documents, ids_only: false)
type = self.class.reference_type
reference_options = self.class.reference_options
nodes = documents.flat_map do |document|
Querying.css(document, "a[data-reference-type='#{type}'].gfm", reference_options).to_a
end
gather_references(nodes, ids_only: ids_only)
end
# Gathers the references for the given HTML nodes. Returns visible
# references and a list of nodes which are not visible to the user
def gather_references(nodes, ids_only: false)
nodes = nodes_user_can_reference(current_user, nodes)
visible = nodes_visible_to_user(current_user, nodes)
{ visible: referenced_by(visible, ids_only: ids_only), nodes: nodes, visible_nodes: visible }
end
# Returns a Hash containing the projects for a given list of HTML nodes.
#
# The returned Hash uses the following format:
#
# { node => project }
#
def projects_for_nodes(nodes)
@projects_for_nodes ||=
grouped_objects_for_nodes(nodes, Project.includes(:project_feature), 'data-project')
end
def can?(user, permission, subject = :global)
Ability.allowed?(user, permission, subject)
end
def find_projects_for_hash_keys(hash)
collection_objects_for_ids(Project, hash.keys)
end
private
attr_reader :context
def current_user
context.current_user
end
# When a feature is disabled or visible only for
# team members we should not allow team members
# see reference comments.
# Override this method on subclasses
# to check if user can read resource
def can_read_reference?(user, ref_project, node)
raise NotImplementedError
end
def lazy(&block)
Gitlab::Lazy.new(&block)
end
def collection_cache
Gitlab::SafeRequestStore[:banzai_collection_cache] ||= Hash.new do |hash, key|
hash[key] = {}
end
end
# For any preloading of project associations
# needed to avoid N+1s.
# Note: `projects` param is a hash of { node => project }.
# See #projects_for_nodes for more information.
def preload_associations(projects, user)
::Preloaders::ProjectPolicyPreloader.new(projects.values, user).execute
end
end
end
end