1# frozen_string_literal: true
2
3# rubocop:disable Rails/ActiveRecordAliases
4class WikiPage
5  include Gitlab::Utils::StrongMemoize
6
7  PageChangedError = Class.new(StandardError)
8  PageRenameError = Class.new(StandardError)
9  FrontMatterTooLong = Class.new(StandardError)
10
11  include ActiveModel::Validations
12  include ActiveModel::Conversion
13  include StaticModel
14  extend ActiveModel::Naming
15
16  delegate :content, :front_matter, to: :parsed_content
17
18  def self.primary_key
19    'slug'
20  end
21
22  def self.model_name
23    ActiveModel::Name.new(self, nil, 'wiki')
24  end
25
26  def eql?(other)
27    return false unless other.present? && other.is_a?(self.class)
28
29    slug == other.slug && wiki.container == other.wiki.container
30  end
31
32  alias_method :==, :eql?
33
34  def self.unhyphenize(name)
35    name.gsub(/-+/, ' ')
36  end
37
38  def to_key
39    [:slug]
40  end
41
42  validates :title, presence: true
43  validate :validate_path_limits, if: :title_changed?
44  validate :validate_content_size_limit, if: :content_changed?
45
46  # The GitLab Wiki instance.
47  attr_reader :wiki
48  delegate :container, to: :wiki
49
50  # The raw Gitlab::Git::WikiPage instance.
51  attr_reader :page
52
53  # The attributes Hash used for storing and validating
54  # new Page values before writing to the raw repository.
55  attr_accessor :attributes
56
57  def hook_attrs
58    Gitlab::HookData::WikiPageBuilder.new(self).build
59  end
60
61  # Construct a new WikiPage
62  #
63  # @param [Wiki] wiki
64  # @param [Gitlab::Git::WikiPage] page
65  def initialize(wiki, page = nil)
66    @wiki       = wiki
67    @page       = page
68    @attributes = {}.with_indifferent_access
69
70    set_attributes if persisted?
71  end
72
73  # The escaped URL path of this page.
74  def slug
75    attributes[:slug].presence || wiki.wiki.preview_slug(title, format)
76  end
77  alias_method :id, :slug # required to use build_stubbed
78
79  alias_method :to_param, :slug
80
81  def human_title
82    return 'Home' if title == Wiki::HOMEPAGE
83
84    title
85  end
86
87  # The formatted title of this page.
88  def title
89    attributes[:title] || ''
90  end
91
92  # Sets the title of this page.
93  def title=(new_title)
94    attributes[:title] = new_title
95  end
96
97  def raw_content
98    attributes[:content] ||= page&.text_data
99  end
100
101  # The hierarchy of the directory this page is contained in.
102  def directory
103    wiki.page_title_and_dir(slug)&.last.to_s
104  end
105
106  # The markup format for the page.
107  def format
108    attributes[:format] || :markdown
109  end
110
111  # The commit message for this page version.
112  def message
113    version.try(:message)
114  end
115
116  # The GitLab Commit instance for this page.
117  def version
118    return unless persisted?
119
120    @version ||= @page.version
121  end
122
123  def path
124    return unless persisted?
125
126    @path ||= @page.path
127  end
128
129  # Returns a CommitCollection
130  #
131  # Queries the commits for current page's path, equivalent to
132  # `git log path/to/page`. Filters and options supported:
133  # https://gitlab.com/gitlab-org/gitaly/-/blob/master/proto/commit.proto#L322-344
134  def versions(options = {})
135    return [] unless persisted?
136
137    default_per_page = Kaminari.config.default_per_page
138    offset = [options[:page].to_i - 1, 0].max * options.fetch(:per_page, default_per_page)
139
140    wiki.repository.commits('HEAD',
141                            path: page.path,
142                            limit: options.fetch(:limit, default_per_page),
143                            offset: offset)
144  end
145
146  def count_versions
147    return [] unless persisted?
148
149    wiki.wiki.count_page_versions(page.path)
150  end
151
152  def last_version
153    @last_version ||= versions(limit: 1).first
154  end
155
156  def last_commit_sha
157    last_version&.sha
158  end
159
160  # Returns boolean True or False if this instance
161  # is an old version of the page.
162  def historical?
163    return false unless last_commit_sha && version
164
165    page.historical? && last_commit_sha != version.sha
166  end
167
168  # Returns boolean True or False if this instance
169  # is the latest commit version of the page.
170  def latest?
171    !historical?
172  end
173
174  # Returns boolean True or False if this instance
175  # has been fully created on disk or not.
176  def persisted?
177    page.present?
178  end
179
180  # Creates a new Wiki Page.
181  #
182  # attr - Hash of attributes to set on the new page.
183  #       :title   - The title (optionally including dir) for the new page.
184  #       :content - The raw markup content.
185  #       :format  - Optional symbol representing the
186  #                  content format. Can be any type
187  #                  listed in the Wiki::MARKUPS
188  #                  Hash.
189  #       :message - Optional commit message to set on
190  #                  the new page.
191  #
192  # Returns the String SHA1 of the newly created page
193  # or False if the save was unsuccessful.
194  def create(attrs = {})
195    update_attributes(attrs)
196
197    save do
198      wiki.create_page(title, content, format, attrs[:message])
199    end
200  end
201
202  # Updates an existing Wiki Page, creating a new version.
203  #
204  # attrs - Hash of attributes to be updated on the page.
205  #        :content         - The raw markup content to replace the existing.
206  #        :format          - Optional symbol representing the content format.
207  #                           See Wiki::MARKUPS Hash for available formats.
208  #        :message         - Optional commit message to set on the new version.
209  #        :last_commit_sha - Optional last commit sha to validate the page unchanged.
210  #        :title           - The Title (optionally including dir) to replace existing title
211  #
212  # Returns the String SHA1 of the newly created page
213  # or False if the save was unsuccessful.
214  def update(attrs = {})
215    last_commit_sha = attrs.delete(:last_commit_sha)
216
217    if last_commit_sha && last_commit_sha != self.last_commit_sha
218      raise PageChangedError, s_(
219        'WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{wikiLinkStart}the page%{wikiLinkEnd} and make sure your changes will not unintentionally remove theirs.')
220    end
221
222    update_attributes(attrs)
223
224    if title.present? && title_changed? && wiki.find_page(title).present?
225      attributes[:title] = page.title
226      raise PageRenameError, s_('WikiEdit|There is already a page with the same title in that path.')
227    end
228
229    save do
230      wiki.update_page(
231        page,
232        content: raw_content,
233        format: format,
234        message: attrs[:message],
235        title: title
236      )
237    end
238  end
239
240  # Destroys the Wiki Page.
241  #
242  # Returns boolean True or False.
243  def delete
244    if wiki.delete_page(page)
245      true
246    else
247      false
248    end
249  end
250
251  # Relative path to the partial to be used when rendering collections
252  # of this object.
253  def to_partial_path
254    '../shared/wikis/wiki_page'
255  end
256
257  def sha
258    page.version&.sha
259  end
260
261  def title_changed?
262    if persisted?
263      # A page's `title` will be returned from Gollum/Gitaly with any +<>
264      # characters changed to -, whereas the `path` preserves these characters.
265      path_without_extension = Pathname(page.path).sub_ext('').to_s
266      old_title, old_dir = wiki.page_title_and_dir(self.class.unhyphenize(path_without_extension))
267      new_title, new_dir = wiki.page_title_and_dir(self.class.unhyphenize(title))
268
269      new_title != old_title || (title.include?('/') && new_dir != old_dir)
270    else
271      title.present?
272    end
273  end
274
275  def content_changed?
276    if persisted?
277      # gollum-lib always converts CRLFs to LFs in Gollum::Wiki#normalize,
278      # so we need to do the same here.
279      # Also see https://gitlab.com/gitlab-org/gitlab/-/issues/21431
280      raw_content.delete("\r") != page&.text_data
281    else
282      raw_content.present?
283    end
284  end
285
286  # Updates the current @attributes hash by merging a hash of params
287  def update_attributes(attrs)
288    attrs[:title] = process_title(attrs[:title]) if attrs[:title].present?
289    update_front_matter(attrs)
290
291    attrs.slice!(:content, :format, :message, :title)
292    clear_memoization(:parsed_content) if attrs.has_key?(:content)
293
294    attributes.merge!(attrs)
295  end
296
297  def to_ability_name
298    'wiki_page'
299  end
300
301  def version_commit_timestamp
302    version&.commit&.committed_date
303  end
304
305  def diffs(diff_options = {})
306    Gitlab::Diff::FileCollection::WikiPage.new(self, diff_options: diff_options)
307  end
308
309  private
310
311  def serialize_front_matter(hash)
312    return '' unless hash.present?
313
314    YAML.dump(hash.transform_keys(&:to_s)) + "---\n"
315  end
316
317  def update_front_matter(attrs)
318    return unless Gitlab::WikiPages::FrontMatterParser.enabled?(container)
319    return unless attrs.has_key?(:front_matter)
320
321    fm_yaml = serialize_front_matter(attrs[:front_matter])
322    raise FrontMatterTooLong if fm_yaml.size > Gitlab::WikiPages::FrontMatterParser::MAX_FRONT_MATTER_LENGTH
323
324    attrs[:content] = fm_yaml + (attrs[:content].presence || content)
325  end
326
327  def parsed_content
328    strong_memoize(:parsed_content) do
329      Gitlab::WikiPages::FrontMatterParser.new(raw_content, container).parse
330    end
331  end
332
333  # Process and format the title based on the user input.
334  def process_title(title)
335    return if title.blank?
336
337    title = deep_title_squish(title)
338    current_dirname = File.dirname(title)
339
340    if persisted?
341      return title[1..] if current_dirname == '/'
342      return File.join([directory.presence, title].compact) if current_dirname == '.'
343    end
344
345    title
346  end
347
348  # This method squishes all the filename
349  # i.e: '   foo   /  bar  / page_name' => 'foo/bar/page_name'
350  def deep_title_squish(title)
351    components = title.split(File::SEPARATOR).map(&:squish)
352
353    File.join(components)
354  end
355
356  def set_attributes
357    attributes[:slug] = @page.url_path
358    attributes[:title] = @page.title
359    attributes[:format] = @page.format
360  end
361
362  def save
363    return false unless valid?
364
365    unless yield
366      errors.add(:base, wiki.error_message)
367      return false
368    end
369
370    @page = wiki.find_page(title).page
371    set_attributes
372
373    true
374  end
375
376  def validate_path_limits
377    return unless title.present?
378
379    *dirnames, filename = title.split('/')
380
381    if filename && filename.bytesize > Gitlab::WikiPages::MAX_TITLE_BYTES
382      errors.add(:title, _("exceeds the limit of %{bytes} bytes") % {
383        bytes: Gitlab::WikiPages::MAX_TITLE_BYTES
384      })
385    end
386
387    invalid_dirnames = dirnames.select { |d| d.bytesize > Gitlab::WikiPages::MAX_DIRECTORY_BYTES }
388    invalid_dirnames.each do |dirname|
389      errors.add(:title, _('exceeds the limit of %{bytes} bytes for directory name "%{dirname}"') % {
390        bytes: Gitlab::WikiPages::MAX_DIRECTORY_BYTES,
391        dirname: dirname
392      })
393    end
394  end
395
396  def validate_content_size_limit
397    current_value = raw_content.to_s.bytesize
398    max_size = Gitlab::CurrentSettings.wiki_page_max_content_bytes
399    return if current_value <= max_size
400
401    errors.add(:content, _('is too long (%{current_value}). The maximum size is %{max_size}.') % {
402      current_value: ActiveSupport::NumberHelper.number_to_human_size(current_value),
403      max_size: ActiveSupport::NumberHelper.number_to_human_size(max_size)
404    })
405  end
406end
407