1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227
|
# frozen_string_literal: true
# This module takes care of updating cache columns for Markdown-containing
# fields. Use like this in the body of your class:
#
# include CacheMarkdownField
# cache_markdown_field :foo
# cache_markdown_field :bar
# cache_markdown_field :baz, pipeline: :single_line
# cache_markdown_field :baz, whitelisted: true
#
# Corresponding foo_html, bar_html and baz_html fields should exist.
module CacheMarkdownField
extend ActiveSupport::Concern
# changes to these attributes cause the cache to be invalidates
INVALIDATED_BY = %w[author project].freeze
def skip_project_check?
false
end
def can_cache_field?(field)
true
end
attr_accessor :skip_markdown_cache_validation
alias_method :skip_markdown_cache_validation?, :skip_markdown_cache_validation
# Returns the default Banzai render context for the cached markdown field.
def banzai_render_context(field)
raise ArgumentError, "Unknown field: #{field.inspect}" unless
cached_markdown_fields.key?(field)
# Always include a project key, or Banzai complains
project = self.project if self.respond_to?(:project)
group = self.group if self.respond_to?(:group)
context = cached_markdown_fields[field].merge(project: project, group: group)
# Banzai is less strict about authors, so don't always have an author key
context[:author] = self.author if self.respond_to?(:author)
context[:user] = self.parent_user if Feature.enabled?(:personal_snippet_reference_filters, context[:author])
context
end
def rendered_field_content(markdown_field)
return unless can_cache_field?(markdown_field)
options = { skip_project_check: skip_project_check? }
Banzai::Renderer.cacheless_render_field(self, markdown_field, options)
end
# Update every applicable column in a row if any one is invalidated, as we only store
# one version per row
def refresh_markdown_cache
updates = cached_markdown_fields.markdown_fields.to_h do |markdown_field|
[
cached_markdown_fields.html_field(markdown_field),
rendered_field_content(markdown_field)
]
end
updates['cached_markdown_version'] = latest_cached_markdown_version
updates.each { |field, data| write_markdown_field(field, data) }
end
def refresh_markdown_cache!
updates = refresh_markdown_cache
if updates.present? && save_markdown(updates)
# save_markdown updates DB columns directly, so compute and save mentions
# by calling store_mentions! or we end-up with missing mentions although those
# would appear in the notes, descriptions, etc in the UI
store_mentions! if mentionable_attributes_changed?(updates)
end
end
def cached_html_up_to_date?(markdown_field)
return false if cached_html_for(markdown_field).nil? && __send__(markdown_field).present? # rubocop:disable GitlabSecurity/PublicSend
html_field = cached_markdown_fields.html_field(markdown_field)
markdown_changed = markdown_field_changed?(markdown_field)
html_changed = markdown_field_changed?(html_field)
latest_cached_markdown_version == cached_markdown_version &&
(html_changed || markdown_changed == html_changed)
end
def invalidated_markdown_cache?
cached_markdown_fields.html_fields.any? { |html_field| attribute_invalidated?(html_field) }
end
def attribute_invalidated?(attr)
__send__("#{attr}_invalidated?") # rubocop:disable GitlabSecurity/PublicSend
end
def cached_html_for(markdown_field)
raise ArgumentError, "Unknown field: #{markdown_field}" unless
cached_markdown_fields.key?(markdown_field)
__send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend
end
# Updates the markdown cache if necessary, then returns the field
# Unlike `cached_html_for` it returns `nil` if the field does not exist
def updated_cached_html_for(markdown_field)
return unless cached_markdown_fields.key?(markdown_field)
if attribute_invalidated?(cached_markdown_fields.html_field(markdown_field))
# Invalidated due to Markdown content change
# We should not persist the updated HTML here since this will depend on whether the
# Markdown content change will be persisted. Both will be persisted together when the model is saved.
if changed_attributes.key?(markdown_field)
refresh_markdown_cache
else
# Invalidated due to stale HTML cache
# This could happen when the Markdown cache version is bumped
# or when a model is imported and the HTML is empty.
# We persist the updated HTML here so that subsequent calls
# to this method do not have to regenerate the HTML again.
refresh_markdown_cache!
end
end
cached_html_for(markdown_field)
end
def latest_cached_markdown_version
# because local_markdown_version is stored in application_settings which uses
# cached_markdown_version too, we check explicitly to avoid an endless loop.
local_version = local_markdown_version if respond_to?(:has_attribute?) && has_attribute?(:local_markdown_version)
# rubocop:disable Gitlab/ModuleWithInstanceVariables -- acceptable use case
# See https://docs.gitlab.com/ee/development/module_with_instance_variables.html#acceptable-use
@latest_cached_markdown_version ||= Gitlab::MarkdownCache.latest_cached_markdown_version(
local_version: local_version
)
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
def parent_user
nil
end
def store_mentions!
# We can only store mentions if the mentionable is a database object
return unless self.is_a?(ApplicationRecord)
identifier = user_mention_identifier
# this may happen due to notes polymorphism, so noteable_id may point to a record
# that no longer exists as we cannot have FK on noteable_id
return if identifier.blank?
refs = all_references(self.author)
references = {}
references[:mentioned_users_ids] = mentioned_filtered_user_ids_for(refs)
references[:mentioned_groups_ids] = refs.mentioned_group_ids.presence
references[:mentioned_projects_ids] = refs.mentioned_project_ids.presence
if references.compact.any?
user_mention_class.upsert(references.merge(identifier), unique_by: identifier.compact.keys)
else
user_mention_class.delete_by(identifier)
end
true
end
# Overriden on objects that needs to filter
# mentioned users that cannot read them, for example,
# guest users that are referenced on a confidential note.
def mentioned_filtered_user_ids_for(refs)
refs.mentioned_user_ids.presence
end
def mentionable_attributes_changed?(changes = saved_changes)
return false unless is_a?(Mentionable)
self.class.mentionable_attrs.any? do |attr|
changes.key?(cached_markdown_fields.html_field(attr.first)) &&
changes.fetch(cached_markdown_fields.html_field(attr.first)).last.present?
end
end
included do
cattr_reader :cached_markdown_fields do
Gitlab::MarkdownCache::FieldData.new
end
if self < ActiveRecord::Base
include Gitlab::MarkdownCache::ActiveRecord::Extension
else
prepend Gitlab::MarkdownCache::Redis::Extension
end
end
class_methods do
private
# Specify that a field is markdown. Its rendered output will be cached in
# a corresponding _html field. Any custom rendering options may be provided
# as a context.
def cache_markdown_field(markdown_field, context = {})
cached_markdown_fields[markdown_field] = context
html_field = cached_markdown_fields.html_field(markdown_field)
invalidation_method = "#{html_field}_invalidated?".to_sym
# The HTML becomes invalid if any dependent fields change. For now, assume
# author and project invalidate the cache in all circumstances.
define_method(invalidation_method) do
return false if skip_markdown_cache_validation?
changed_fields = changed_attributes.keys
invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
!invalidations.empty? || !cached_html_up_to_date?(markdown_field)
end
end
end
end
CacheMarkdownField.prepend_mod
|