summaryrefslogtreecommitdiff
path: root/app/models/concerns/cache_markdown_field.rb
blob: 90bd6490a02e901b2fc4816756217b81ea328d98 (plain)
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
# 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
  # Knows about the relationship between markdown and html field names, and
  # stores the rendering contexts for the latter
  class FieldData
    extend Forwardable

    def initialize
      @data = {}
    end

    def_delegators :@data, :[], :[]=
    def_delegator :@data, :keys, :markdown_fields

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

    def html_fields
      markdown_fields.map {|field| html_field(field) }
    end
  end

  # Dynamic registries don't really work in Rails as it's not guaranteed that
  # every class will be loaded, so hardcode the list.
  CACHING_CLASSES = %w[
    AbuseReport
    Appearance
    ApplicationSetting
    BroadcastMessage
    Issue
    Label
    MergeRequest
    Milestone
    Namespace
    Note
    Project
    Release
    Snippet
  ]

  def self.caching_classes
    CACHING_CLASSES.map(&:constantize)
  end

  extend ActiveSupport::Concern

  included do
    cattr_reader :cached_markdown_fields do
      FieldData.new
    end

    # 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)

      # Always include a project key, or Banzai complains
      project = self.project if self.respond_to?(:project)
      context = cached_markdown_fields[field].merge(project: project)

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

      context
    end

    # Allow callers to look up the cache field name, rather than hardcoding it
    def markdown_cache_field_for(field)
      raise ArgumentError.new("Unknown field: #{field}") unless
        cached_markdown_fields.markdown_fields.include?(field)

      cached_markdown_fields.html_field(field)
    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

      cached_markdown_fields.html_fields.each do |field|
        attrs.delete(field)
      end

      attrs
    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 = {})
      raise "Add #{self} to CacheMarkdownField::CACHING_CLASSES" unless
        CacheMarkdownField::CACHING_CLASSES.include?(self.to_s)

      cached_markdown_fields[markdown_field] = context

      html_field = cached_markdown_fields.html_field(markdown_field)
      cache_method = "#{markdown_field}_cache_refresh".to_sym
      invalidation_method = "#{html_field}_invalidated?".to_sym

      define_method(cache_method) do
        html = Banzai::Renderer.cacheless_render_field(self, markdown_field)
        __send__("#{html_field}=", html)
        true
      end

      # 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
        invalidations = changed_fields & [markdown_field.to_s, "author", "project"]
        !invalidations.empty?
      end

      before_save cache_method, if: invalidation_method
    end
  end
end