debian-mirror-gitlab/app/models/concerns/cache_markdown_field.rb

187 lines
5.9 KiB
Ruby
Raw Normal View History

2018-11-20 20:47:30 +05:30
# frozen_string_literal: true
2016-11-03 12:29:30 +05:30
# 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
#
# Corresponding foo_html, bar_html and baz_html fields should exist.
module CacheMarkdownField
2017-08-17 22:00:37 +05:30
extend ActiveSupport::Concern
# Increment this number every time the renderer changes its output
2018-10-15 14:42:47 +05:30
CACHE_REDCARPET_VERSION = 3
CACHE_COMMONMARK_VERSION_START = 10
2019-02-13 22:33:31 +05:30
CACHE_COMMONMARK_VERSION = 13
2017-08-17 22:00:37 +05:30
# changes to these attributes cause the cache to be invalidates
INVALIDATED_BY = %w[author project].freeze
2016-11-03 12:29:30 +05:30
# Knows about the relationship between markdown and html field names, and
# stores the rendering contexts for the latter
class FieldData
def initialize
@data = {}
end
2017-08-17 22:00:37 +05:30
delegate :[], :[]=, to: :@data
def markdown_fields
@data.keys
end
2016-11-03 12:29:30 +05:30
def html_field(markdown_field)
"#{markdown_field}_html"
end
def html_fields
markdown_fields.map {|field| html_field(field) }
end
end
2018-11-08 19:23:39 +05:30
class MarkdownEngine
def self.from_version(version = nil)
return :common_mark if version.nil? || version == 0
if version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
:redcarpet
else
:common_mark
end
end
end
2017-08-17 22:00:37 +05:30
def skip_project_check?
false
2016-11-03 12:29:30 +05:30
end
2017-08-17 22:00:37 +05:30
# 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)
2016-11-03 12:29:30 +05:30
2017-08-17 22:00:37 +05:30
# Always include a project key, or Banzai complains
project = self.project if self.respond_to?(:project)
2018-10-15 14:42:47 +05:30
group = self.group if self.respond_to?(:group)
2018-03-17 18:26:18 +05:30
context = cached_markdown_fields[field].merge(project: project, group: group)
2016-11-03 12:29:30 +05:30
2017-08-17 22:00:37 +05:30
# Banzai is less strict about authors, so don't always have an author key
context[:author] = self.author if self.respond_to?(:author)
2016-11-03 12:29:30 +05:30
2018-11-08 19:23:39 +05:30
context[:markdown_engine] = MarkdownEngine.from_version(latest_cached_markdown_version)
2018-10-15 14:42:47 +05:30
2017-08-17 22:00:37 +05:30
context
end
2016-11-03 12:29:30 +05:30
2017-08-17 22:00:37 +05:30
# Update every column in a row if any one is invalidated, as we only store
# one version per row
2018-03-17 18:26:18 +05:30
def refresh_markdown_cache
2017-08-17 22:00:37 +05:30
options = { skip_project_check: skip_project_check? }
2016-11-03 12:29:30 +05:30
2017-08-17 22:00:37 +05:30
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
2018-10-15 14:42:47 +05:30
updates['cached_markdown_version'] = latest_cached_markdown_version
2017-08-17 22:00:37 +05:30
updates.each {|html_field, data| write_attribute(html_field, data) }
2018-03-17 18:26:18 +05:30
end
def refresh_markdown_cache!
updates = refresh_markdown_cache
return unless persisted? && Gitlab::Database.read_write?
2017-08-17 22:00:37 +05:30
2018-03-17 18:26:18 +05:30
update_columns(updates)
2017-08-17 22:00:37 +05:30
end
def cached_html_up_to_date?(markdown_field)
html_field = cached_markdown_fields.html_field(markdown_field)
2018-03-17 18:26:18 +05:30
return false if cached_html_for(markdown_field).nil? && !__send__(markdown_field).nil? # rubocop:disable GitlabSecurity/PublicSend
2016-11-03 12:29:30 +05:30
2017-08-17 22:00:37 +05:30
markdown_changed = attribute_changed?(markdown_field) || false
html_changed = attribute_changed?(html_field) || false
2016-11-03 12:29:30 +05:30
2018-10-15 14:42:47 +05:30
latest_cached_markdown_version == cached_markdown_version &&
2017-08-17 22:00:37 +05:30
(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)
2018-03-17 18:26:18 +05:30
__send__("#{attr}_invalidated?") # rubocop:disable GitlabSecurity/PublicSend
2017-08-17 22:00:37 +05:30
end
def cached_html_for(markdown_field)
raise ArgumentError.new("Unknown field: #{field}") unless
cached_markdown_fields.markdown_fields.include?(markdown_field)
2018-03-17 18:26:18 +05:30
__send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend
2017-08-17 22:00:37 +05:30
end
2018-10-15 14:42:47 +05:30
def latest_cached_markdown_version
2018-11-08 19:23:39 +05:30
return CacheMarkdownField::CACHE_COMMONMARK_VERSION unless cached_markdown_version
2018-10-15 14:42:47 +05:30
if cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
CacheMarkdownField::CACHE_REDCARPET_VERSION
else
CacheMarkdownField::CACHE_COMMONMARK_VERSION
end
end
2017-08-17 22:00:37 +05:30
included do
cattr_reader :cached_markdown_fields do
FieldData.new
2016-11-03 12:29:30 +05:30
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
2017-08-17 22:00:37 +05:30
attrs.delete('cached_markdown_version')
2016-11-03 12:29:30 +05:30
cached_markdown_fields.html_fields.each do |field|
attrs.delete(field)
end
attrs
end
2017-08-17 22:00:37 +05:30
# Using before_update here conflicts with elasticsearch-model somehow
2018-03-17 18:26:18 +05:30
before_create :refresh_markdown_cache, if: :invalidated_markdown_cache?
before_update :refresh_markdown_cache, if: :invalidated_markdown_cache?
2016-11-03 12:29:30 +05:30
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
2017-08-17 22:00:37 +05:30
invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
!invalidations.empty? || !cached_html_up_to_date?(markdown_field)
2016-11-03 12:29:30 +05:30
end
end
end
end