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