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