summaryrefslogtreecommitdiff
path: root/app/models/design_management/design.rb
blob: 0dca6333fa11799017825e41ec1ae3d39a08a498 (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
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# frozen_string_literal: true

module DesignManagement
  class Design < ApplicationRecord
    include Importable
    include Noteable
    include Gitlab::FileTypeDetection
    include Gitlab::Utils::StrongMemoize
    include Referable
    include Mentionable
    include WhereComposite

    belongs_to :project, inverse_of: :designs
    belongs_to :issue

    has_many :actions
    has_many :versions, through: :actions, class_name: 'DesignManagement::Version', inverse_of: :designs
    # This is a polymorphic association, so we can't count on FK's to delete the
    # data
    has_many :notes, as: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
    has_many :user_mentions, class_name: 'DesignUserMention', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent

    has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent

    validates :project, :filename, presence: true
    validates :issue, presence: true, unless: :importing?
    validates :filename, uniqueness: { scope: :issue_id }, length: { maximum: 255 }
    validate :validate_file_is_image

    alias_attribute :title, :filename

    # Pre-fetching scope to include the data necessary to construct a
    # reference using `to_reference`.
    scope :for_reference, -> { includes(issue: [{ project: [:route, :namespace] }]) }

    # A design can be uniquely identified by issue_id and filename
    # Takes one or more sets of composite IDs of the form:
    # `{issue_id: Integer, filename: String}`.
    #
    # @see WhereComposite::where_composite
    #
    # e.g:
    #
    #   by_issue_id_and_filename(issue_id: 1, filename: 'homescreen.jpg')
    #   by_issue_id_and_filename([]) # returns ActiveRecord::NullRelation
    #   by_issue_id_and_filename([
    #     { issue_id: 1, filename: 'homescreen.jpg' },
    #     { issue_id: 2, filename: 'homescreen.jpg' },
    #     { issue_id: 1, filename: 'menu.png' }
    #   ])
    #
    scope :by_issue_id_and_filename, ->(composites) do
      where_composite(%i[issue_id filename], composites)
    end

    # Find designs visible at the given version
    #
    # @param version [nil, DesignManagement::Version]:
    #   the version at which the designs must be visible
    #   Passing `nil` is the same as passing the most current version
    #
    # Restricts to designs
    # - created at least *before* the given version
    # - not deleted as of the given version.
    #
    # As a query, we ascertain this by finding the last event prior to
    # (or equal to) the cut-off, and seeing whether that version was a deletion.
    scope :visible_at_version, -> (version) do
      deletion = ::DesignManagement::Action.events[:deletion]
      designs = arel_table
      actions = ::DesignManagement::Action
        .most_recent.up_to_version(version)
        .arel.as('most_recent_actions')

      join = designs.join(actions)
        .on(actions[:design_id].eq(designs[:id]))

      joins(join.join_sources).where(actions[:event].not_eq(deletion)).order(:id)
    end

    scope :with_filename, -> (filenames) { where(filename: filenames) }
    scope :on_issue, ->(issue) { where(issue_id: issue) }

    # Scope called by our REST API to avoid N+1 problems
    scope :with_api_entity_associations, -> { preload(:issue) }

    # A design is current if the most recent event is not a deletion
    scope :current, -> { visible_at_version(nil) }

    def status
      if new_design?
        :new
      elsif deleted?
        :deleted
      else
        :current
      end
    end

    def deleted?
      most_recent_action&.deletion?
    end

    # A design is visible_in? a version if:
    #   * it was created before that version
    #   * the most recent action before the version was not a deletion
    def visible_in?(version)
      map = strong_memoize(:visible_in) do
        Hash.new do |h, k|
          h[k] = self.class.visible_at_version(k).where(id: id).exists?
        end
      end

      map[version]
    end

    def most_recent_action
      strong_memoize(:most_recent_action) { actions.ordered.last }
    end

    # A reference for a design is the issue reference, indexed by the filename
    # with an optional infix when full.
    #
    # e.g.
    #   #123[homescreen.png]
    #   other-project#72[sidebar.jpg]
    #   #38/designs[transition.gif]
    #   #12["filename with [] in it.jpg"]
    def to_reference(from = nil, full: false)
      infix = full ? '/designs' : ''
      safe_name = Sanitize.fragment(filename)

      "#{issue.to_reference(from, full: full)}#{infix}[#{safe_name}]"
    end

    def self.reference_pattern
      # no-op: We only support link_reference_pattern parsing
    end

    def self.link_reference_pattern
      @link_reference_pattern ||= begin
        path_segment = %r{issues/#{Gitlab::Regex.issue}/designs}
        ext = Regexp.new(Regexp.union(SAFE_IMAGE_EXT + DANGEROUS_IMAGE_EXT).source, Regexp::IGNORECASE)
        valid_char = %r{[^/\s]} # any char that is not a forward slash or whitespace
        filename_pattern = %r{
          (?<url_filename> #{valid_char}+ \. #{ext})
        }x

        super(path_segment, filename_pattern)
      end
    end

    def to_ability_name
      'design'
    end

    def description
      ''
    end

    def new_design?
      strong_memoize(:new_design) { actions.none? }
    end

    def full_path
      @full_path ||= File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", filename)
    end

    def diff_refs
      strong_memoize(:diff_refs) { head_version&.diff_refs }
    end

    def clear_version_cache
      [versions, actions].each(&:reset)
      %i[new_design diff_refs head_sha visible_in most_recent_action].each do |key|
        clear_memoization(key)
      end
    end

    def repository
      project.design_repository
    end

    def user_notes_count
      user_notes_count_service.count
    end

    def after_note_changed(note)
      user_notes_count_service.delete_cache unless note.system?
    end
    alias_method :after_note_created,   :after_note_changed
    alias_method :after_note_destroyed, :after_note_changed

    # Part of the interface of objects we can create events about
    def resource_parent
      project
    end

    private

    def head_version
      strong_memoize(:head_sha) { versions.ordered.first }
    end

    def allow_dangerous_images?
      Feature.enabled?(:design_management_allow_dangerous_images, project)
    end

    def valid_file_extensions
      allow_dangerous_images? ? (SAFE_IMAGE_EXT + DANGEROUS_IMAGE_EXT) : SAFE_IMAGE_EXT
    end

    def validate_file_is_image
      unless image? || (dangerous_image? && allow_dangerous_images?)
        message = _('does not have a supported extension. Only %{extension_list} are supported') % {
          extension_list: valid_file_extensions.to_sentence
        }
        errors.add(:filename, message)
      end
    end

    def user_notes_count_service
      strong_memoize(:user_notes_count_service) do
        ::DesignManagement::DesignUserNotesCountService.new(self) # rubocop: disable CodeReuse/ServiceClass
      end
    end
  end
end