cache_markdown_field.rb 6.7 KB
Newer Older
1 2
# frozen_string_literal: true

3 4 5 6 7 8 9
# 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
10
#     cache_markdown_field :baz, whitelisted: true
11 12 13
#
# Corresponding foo_html, bar_html and baz_html fields should exist.
module CacheMarkdownField
14 15 16
  extend ActiveSupport::Concern

  # Increment this number every time the renderer changes its output
17
  CACHE_COMMONMARK_VERSION_START  = 10
18
  CACHE_COMMONMARK_VERSION        = 14
19 20 21 22

  # changes to these attributes cause the cache to be invalidates
  INVALIDATED_BY = %w[author project].freeze

23 24 25 26 27 28 29
  # Knows about the relationship between markdown and html field names, and
  # stores the rendering contexts for the latter
  class FieldData
    def initialize
      @data = {}
    end

30 31 32 33 34
    delegate :[], :[]=, to: :@data

    def markdown_fields
      @data.keys
    end
35 36 37 38 39 40

    def html_field(markdown_field)
      "#{markdown_field}_html"
    end

    def html_fields
41 42 43 44 45 46 47 48 49
      markdown_fields.map { |field| html_field(field) }
    end

    def html_fields_whitelisted
      markdown_fields.each_with_object([]) do |field, fields|
        if @data[field].fetch(:whitelisted, false)
          fields << html_field(field)
        end
      end
50 51 52
    end
  end

Jarka Kadlecova's avatar
Jarka Kadlecova committed
53 54 55 56
  def skip_project_check?
    false
  end

57 58 59 60
  # Returns the default Banzai render context for the cached markdown field.
  def banzai_render_context(field)
    raise ArgumentError.new("Unknown field: #{field.inspect}") unless
      cached_markdown_fields.markdown_fields.include?(field)
61

62 63
    # Always include a project key, or Banzai complains
    project = self.project if self.respond_to?(:project)
64
    group   = self.group if self.respond_to?(:group)
65
    context = cached_markdown_fields[field].merge(project: project, group: group)
66 67 68

    # Banzai is less strict about authors, so don't always have an author key
    context[:author] = self.author if self.respond_to?(:author)
69

70
    context[:markdown_engine] = :common_mark
71

72 73
    context
  end
74

75 76
  # Update every column in a row if any one is invalidated, as we only store
  # one version per row
77
  def refresh_markdown_cache
78
    options = { skip_project_check: skip_project_check? }
79

80 81 82 83 84 85
    updates = cached_markdown_fields.markdown_fields.map do |markdown_field|
      [
        cached_markdown_fields.html_field(markdown_field),
        Banzai::Renderer.cacheless_render_field(self, markdown_field, options)
      ]
    end.to_h
86
    updates['cached_markdown_version'] = latest_cached_markdown_version
87

88
    updates.each {|html_field, data| write_attribute(html_field, data) }
89 90 91 92 93 94
  end

  def refresh_markdown_cache!
    updates = refresh_markdown_cache

    return unless persisted? && Gitlab::Database.read_write?
95

96
    update_columns(updates)
97 98 99 100 101
  end

  def cached_html_up_to_date?(markdown_field)
    html_field = cached_markdown_fields.html_field(markdown_field)

102
    return false if cached_html_for(markdown_field).nil? && !__send__(markdown_field).nil? # rubocop:disable GitlabSecurity/PublicSend
103

104 105
    markdown_changed = attribute_changed?(markdown_field) || false
    html_changed = attribute_changed?(html_field) || false
106

107
    latest_cached_markdown_version == cached_markdown_version &&
108 109 110 111 112 113 114 115
      (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)
116
    __send__("#{attr}_invalidated?") # rubocop:disable GitlabSecurity/PublicSend
117 118 119 120 121 122
  end

  def cached_html_for(markdown_field)
    raise ArgumentError.new("Unknown field: #{field}") unless
      cached_markdown_fields.markdown_fields.include?(markdown_field)

123
    __send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend
124
  end
125

126
  def latest_cached_markdown_version
Jan Provaznik's avatar
Jan Provaznik committed
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148
    @latest_cached_markdown_version ||= (CacheMarkdownField::CACHE_COMMONMARK_VERSION << 16) | local_version
  end

  def local_version
    # because local_markdown_version is stored in application_settings which
    # uses cached_markdown_version too, we check explicitly to avoid
    # endless loop
    return local_markdown_version if has_attribute?(:local_markdown_version)

    settings = Gitlab::CurrentSettings.current_application_settings

    # Following migrations are not properly isolated and
    # use real models (by calling .ghost method), in these migrations
    # local_markdown_version attribute doesn't exist yet, so we
    # use a default value:
    # db/migrate/20170825104051_migrate_issues_to_ghost_user.rb
    # db/migrate/20171114150259_merge_requests_author_id_foreign_key.rb
    if settings.respond_to?(:local_markdown_version)
      settings.local_markdown_version
    else
      0
    end
149 150
  end

151 152 153
  included do
    cattr_reader :cached_markdown_fields do
      FieldData.new
154 155 156 157 158 159 160
    end

    # Always exclude _html fields from attributes (including serialization).
    # They contain unredacted HTML, which would be a security issue
    alias_method :attributes_before_markdown_cache, :attributes
    def attributes
      attrs = attributes_before_markdown_cache
161 162 163
      html_fields = cached_markdown_fields.html_fields
      whitelisted = cached_markdown_fields.html_fields_whitelisted
      exclude_fields = html_fields - whitelisted
164

165
      exclude_fields.each do |field|
166 167 168
        attrs.delete(field)
      end

169 170 171 172
      if whitelisted.empty?
        attrs.delete('cached_markdown_version')
      end

173 174
      attrs
    end
175

176
    # Using before_update here conflicts with elasticsearch-model somehow
177 178
    before_create :refresh_markdown_cache, if: :invalidated_markdown_cache?
    before_update :refresh_markdown_cache, if: :invalidated_markdown_cache?
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196
  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
        changed_fields = changed_attributes.keys
197 198 199
        invalidations  = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
        invalidations.delete(markdown_field.to_s) if changed_fields.include?("#{markdown_field}_html")

200
        !invalidations.empty? || !cached_html_up_to_date?(markdown_field)
201 202 203 204
      end
    end
  end
end