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