Fix bug where it was possible to submit blank text in various text fields. Caused by `String#blank?` not considering certain Unicode characters as blank. `blank?` is defined as `match?(/\A[[:space:]]*\z/)`, where `[[:space:]]` matches ASCII spaces (space, tab, newline, etc) and Unicode characters in the Space category ([1]). However, there are other space-like characters not in the Space category. This includes U+200B (Zero-Width Space), and many more. It turns out the "Default ignorable code points" [2][3] are what we're after. These are the set of 400 or so formatting and control characters that are invisible when displayed. Note that there are other control characters that aren't invisible when rendered, instead they're shown with a placeholder glyph. These include the ASCII C0 and C1 control codes [4], certain Unicode control characters [5], and unassigned, reserved, and private use codepoints. There is one outlier: the Braille pattern blank (U+2800) [6]. This character is visually blank, but is not considered to be a space or an ignorable code point. [1]: https://codepoints.net/search?gc[]=Z [2]: https://codepoints.net/search?DI=1 [3]: https://www.unicode.org/review/pr-5.html [4]: https://codepoints.net/search?gc[]=Cc [5]: https://codepoints.net/search?gc[]=Cf [6]: https://codepoints.net/U+2800 [7]: https://en.wikipedia.org/wiki/Whitespace_character [8]: https://character.construction/blanks [9]: https://invisible-characters.com
234 lines
6.6 KiB
Ruby
234 lines
6.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class WikiPage < ApplicationRecord
|
|
class RevertError < StandardError; end
|
|
|
|
META_WIKIS = ["list_of_", "tag_group:", "pool_group:", "howto:", "about:", "help:", "template:","api:"]
|
|
|
|
after_save :create_version
|
|
|
|
normalize :title, :normalize_title
|
|
normalize :body, :normalize_text
|
|
normalize :other_names, :normalize_other_names
|
|
|
|
array_attribute :other_names # XXX must come after `normalize :other_names`
|
|
|
|
validates :title, tag_name: true, presence: true, uniqueness: true, if: :title_changed?
|
|
validates :body, visible_string: true, unless: -> { is_deleted? || other_names.present? }
|
|
validate :validate_rename
|
|
validate :validate_other_names
|
|
|
|
has_one :tag, :foreign_key => "name", :primary_key => "title"
|
|
has_one :artist, -> { active }, foreign_key: "name", primary_key: "title"
|
|
has_many :versions, -> {order("wiki_page_versions.id ASC")}, :class_name => "WikiPageVersion", :dependent => :destroy
|
|
|
|
deletable
|
|
has_dtext_links :body
|
|
|
|
module SearchMethods
|
|
def find_by_id_or_title(id)
|
|
if id =~ /\A\d+\z/
|
|
[find_by_id(id), :id]
|
|
else
|
|
[find_by_title(normalize_title(id)), :title]
|
|
end
|
|
end
|
|
|
|
def titled(title)
|
|
where(title: normalize_title(title))
|
|
end
|
|
|
|
def title_matches(title)
|
|
where_like(:title, normalize_title(title))
|
|
end
|
|
|
|
def other_names_include(name)
|
|
name = normalize_other_name(name)
|
|
subquery = WikiPage.from("unnest(other_names) AS other_name").where_iequals("other_name", name)
|
|
where(id: subquery)
|
|
end
|
|
|
|
def other_names_match(name)
|
|
if name =~ /\*/
|
|
subquery = WikiPage.from("unnest(other_names) AS other_name").where_ilike("other_name", normalize_other_name(name))
|
|
where(id: subquery)
|
|
else
|
|
other_names_include(name)
|
|
end
|
|
end
|
|
|
|
def default_order
|
|
order(updated_at: :desc)
|
|
end
|
|
|
|
def search(params, current_user)
|
|
q = search_attributes(params, [:id, :created_at, :updated_at, :is_locked, :is_deleted, :body, :title, :other_names, :tag, :artist, :dtext_links], current_user: current_user)
|
|
q = q.where_text_matches([:title, :body], params[:title_or_body_matches])
|
|
|
|
if params[:title_normalize].present?
|
|
q = q.where_like(:title, normalize_title(params[:title_normalize]))
|
|
end
|
|
|
|
if params[:other_names_match].present?
|
|
q = q.other_names_match(params[:other_names_match])
|
|
end
|
|
|
|
if params[:linked_to].present?
|
|
q = q.linked_to(params[:linked_to])
|
|
end
|
|
|
|
if params[:not_linked_to].present?
|
|
q = q.not_linked_to(params[:not_linked_to])
|
|
end
|
|
|
|
if params[:hide_deleted].to_s.truthy?
|
|
q = q.where("is_deleted = false")
|
|
end
|
|
|
|
if params[:other_names_present].to_s.truthy?
|
|
q = q.where("other_names is not null and other_names != '{}'")
|
|
elsif params[:other_names_present].to_s.falsy?
|
|
q = q.where("other_names is null or other_names = '{}'")
|
|
end
|
|
|
|
case params[:order]
|
|
when "title"
|
|
q = q.order(title: :asc)
|
|
when "post_count"
|
|
q = q.includes(:tag).order("tags.post_count desc nulls last").references(:tags)
|
|
else
|
|
q = q.apply_default_order(params)
|
|
end
|
|
|
|
q
|
|
end
|
|
end
|
|
|
|
extend SearchMethods
|
|
|
|
def validate_rename
|
|
return unless title_changed?
|
|
|
|
tag_was = Tag.find_by_name(Tag.normalize_name(title_was))
|
|
if tag_was.present? && !tag_was.empty?
|
|
warnings.add(:base, %{Warning: {{#{title_was}}} still has #{tag_was.post_count} #{"post".pluralize(tag_was.post_count)}. Be sure to move the posts})
|
|
end
|
|
|
|
broken_wikis = WikiPage.linked_to(title_was)
|
|
if broken_wikis.count > 0
|
|
broken_wiki_search = Routes.wiki_pages_path(search: { linked_to: title_was })
|
|
warnings.add(:base, %{Warning: [[#{title_was}]] is still linked from "#{broken_wikis.count} #{"other wiki page".pluralize(broken_wikis.count)}":[#{broken_wiki_search}]. Update #{(broken_wikis.count > 1) ? "these wikis" : "this wiki"} to link to [[#{title}]] instead})
|
|
end
|
|
end
|
|
|
|
def validate_other_names
|
|
if other_names.present? && tag&.artist?
|
|
errors.add(:base, "An artist wiki can't have other names")
|
|
end
|
|
end
|
|
|
|
def revert_to(version)
|
|
if id != version.wiki_page_id
|
|
raise RevertError, "You cannot revert to a previous version of another wiki page."
|
|
end
|
|
|
|
self.title = version.title
|
|
self.body = version.body
|
|
self.is_locked = version.is_locked
|
|
self.other_names = version.other_names
|
|
end
|
|
|
|
def revert_to!(version)
|
|
revert_to(version)
|
|
save!
|
|
end
|
|
|
|
def self.normalize_title(title)
|
|
title.to_s.downcase.delete_prefix("~").gsub(/[[:space:]]+/, "_").squeeze("_").gsub(/\A_|_\z/, "")
|
|
end
|
|
|
|
def self.normalize_other_names(other_names)
|
|
other_names.map { |name| normalize_other_name(name) }.uniq.reject(&:blank?)
|
|
end
|
|
|
|
def self.normalize_other_name(name)
|
|
name.to_s.unicode_normalize(:nfkc).normalize_whitespace.gsub(/[[:space:]]+/, "_").squeeze("_").gsub(/\A_|_\z/, "")
|
|
end
|
|
|
|
def pretty_title
|
|
title.tr("_", " ")
|
|
end
|
|
|
|
def self.is_meta_wiki?(title)
|
|
title.present? && title.starts_with?(*META_WIKIS)
|
|
end
|
|
|
|
def is_meta_wiki?
|
|
WikiPage.is_meta_wiki?(title)
|
|
end
|
|
|
|
def wiki_page_changed?
|
|
saved_change_to_title? || saved_change_to_body? || saved_change_to_is_locked? || saved_change_to_is_deleted? || saved_change_to_other_names?
|
|
end
|
|
|
|
def merge_version
|
|
prev = versions.last
|
|
prev.update(title: title, body: body, is_locked: is_locked, is_deleted: is_deleted, other_names: other_names)
|
|
end
|
|
|
|
def merge_version?
|
|
prev = versions.last
|
|
prev && prev.updater_id == CurrentUser.id && prev.updated_at > 1.hour.ago
|
|
end
|
|
|
|
def create_new_version
|
|
versions.create(
|
|
:updater_id => CurrentUser.id,
|
|
:title => title,
|
|
:body => body,
|
|
:is_locked => is_locked,
|
|
:is_deleted => is_deleted,
|
|
:other_names => other_names
|
|
)
|
|
end
|
|
|
|
def create_version
|
|
if wiki_page_changed?
|
|
if merge_version?
|
|
merge_version
|
|
else
|
|
create_new_version
|
|
end
|
|
end
|
|
end
|
|
|
|
def tags
|
|
titles = DText.parse_wiki_titles(body).uniq
|
|
tags = Tag.nonempty.undeprecated.where(name: titles).pluck(:name)
|
|
tags += TagAlias.active.where(antecedent_name: titles).pluck(:antecedent_name)
|
|
TagAlias.to_aliased(titles & tags)
|
|
end
|
|
|
|
def self.rewrite_wiki_links!(old_name, new_name)
|
|
WikiPage.linked_to(old_name).each do |wiki|
|
|
wiki.lock!.update!(body: DText.rewrite_wiki_links(wiki.body, old_name, new_name))
|
|
end
|
|
end
|
|
|
|
def to_param
|
|
if title =~ /\A\d+\z/
|
|
"~#{title}"
|
|
else
|
|
title
|
|
end
|
|
end
|
|
|
|
def self.model_restriction(table)
|
|
super.where(table[:is_deleted].eq(false))
|
|
end
|
|
|
|
def self.available_includes
|
|
[:tag, :artist, :dtext_links]
|
|
end
|
|
end
|