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
223 lines
5.6 KiB
Ruby
223 lines
5.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class FavoriteGroup < ApplicationRecord
|
|
belongs_to :creator, class_name: "User"
|
|
|
|
before_validation :normalize_name
|
|
|
|
validates :name, visible_string: true
|
|
validates :name, uniqueness: { case_sensitive: false, scope: :creator_id }
|
|
validate :validate_name, if: :name_changed?
|
|
validate :creator_can_create_favorite_groups, :on => :create
|
|
validate :validate_number_of_posts
|
|
validate :validate_posts
|
|
validate :validate_can_enable_privacy
|
|
|
|
array_attribute :post_ids, parse: /\d+/, cast: :to_i
|
|
|
|
scope :is_public, -> { where(is_public: true) }
|
|
scope :is_private, -> { where(is_public: false) }
|
|
|
|
module SearchMethods
|
|
def for_post(post_id)
|
|
where_array_includes_any(:post_ids, [post_id])
|
|
end
|
|
|
|
def name_contains(name)
|
|
name = normalize_name(name)
|
|
name = "*#{name}*" unless name =~ /\*/
|
|
where_ilike(:name, name)
|
|
end
|
|
|
|
def visible(user)
|
|
if user.is_owner?
|
|
all
|
|
elsif user.is_anonymous?
|
|
is_public
|
|
else
|
|
is_public.or(where(creator: user))
|
|
end
|
|
end
|
|
|
|
def search(params, current_user)
|
|
q = search_attributes(params, [:id, :created_at, :updated_at, :name, :is_public, :post_ids, :creator], current_user: current_user)
|
|
|
|
if params[:name_contains].present?
|
|
q = q.name_contains(params[:name_contains])
|
|
end
|
|
|
|
case params[:order]
|
|
when "name"
|
|
q = q.order(name: :asc, id: :desc)
|
|
when "created_at"
|
|
q = q.order(id: :desc)
|
|
when "updated_at"
|
|
q = q.order(updated_at: :desc)
|
|
when "post_count"
|
|
q = q.order(Arel.sql("cardinality(post_ids) desc")).order(id: :desc)
|
|
else
|
|
q = q.apply_default_order(params)
|
|
end
|
|
|
|
q
|
|
end
|
|
end
|
|
|
|
extend SearchMethods
|
|
|
|
def creator_can_create_favorite_groups
|
|
if creator.favorite_groups.count >= creator.favorite_group_limit
|
|
error = "You can only keep up to #{creator.favorite_group_limit} favorite groups."
|
|
if !creator.is_gold?
|
|
error += " Upgrade your account to create more."
|
|
end
|
|
errors.add(:base, error)
|
|
end
|
|
end
|
|
|
|
def validate_number_of_posts
|
|
if post_count > 10_000
|
|
errors.add(:base, "Favorite groups can have up to 10,000 posts each")
|
|
end
|
|
end
|
|
|
|
def validate_posts
|
|
added_post_ids = post_ids - post_ids_was
|
|
existing_post_ids = Post.where(id: added_post_ids).pluck(:id)
|
|
nonexisting_post_ids = added_post_ids - existing_post_ids
|
|
|
|
if nonexisting_post_ids.present?
|
|
errors.add(:base, "Cannot add invalid post(s) to favgroup: #{nonexisting_post_ids.to_sentence}")
|
|
end
|
|
|
|
duplicate_post_ids = post_ids.group_by(&:itself).transform_values(&:size).select { |_id, count| count > 1 }.keys
|
|
if duplicate_post_ids.present?
|
|
errors.add(:base, "Favgroup already contains post #{duplicate_post_ids.to_sentence}")
|
|
end
|
|
end
|
|
|
|
def validate_can_enable_privacy
|
|
if is_public_change == [true, false] && !Pundit.policy!(creator, self).can_enable_privacy?
|
|
errors.add(:base, "Can't enable privacy without a Gold account")
|
|
end
|
|
end
|
|
|
|
def validate_name
|
|
case name
|
|
when /\A(any|none)\z/i
|
|
errors.add(:name, "cannot be '#{name}'")
|
|
when /,/
|
|
errors.add(:name, "cannot contain commas")
|
|
when /\*/
|
|
errors.add(:name, "cannot contain asterisks")
|
|
when /\A_/
|
|
errors.add(:name, "cannot begin with an underscore")
|
|
when /_\z/
|
|
errors.add(:name, "cannot end with an underscore")
|
|
when /__/
|
|
errors.add(:name, "cannot contain consecutive underscores")
|
|
when /[^[:graph:]]/
|
|
errors.add(:name, "cannot contain non-printable characters")
|
|
when ""
|
|
errors.add(:name, "cannot be blank")
|
|
when /\A[0-9]+\z/
|
|
errors.add(:name, "cannot contain only digits")
|
|
end
|
|
end
|
|
|
|
def self.normalize_name(name)
|
|
name.gsub(/[_[:space:]]+/, "_").gsub(/\A_|_\z/, "")
|
|
end
|
|
|
|
def normalize_name
|
|
self.name = FavoriteGroup.normalize_name(name)
|
|
end
|
|
|
|
def self.name_or_id_matches(name, user)
|
|
if name =~ /\A\d+\z/
|
|
where(id: name)
|
|
else
|
|
where(creator: user).where_iequals(:name, normalize_name(name))
|
|
end
|
|
end
|
|
|
|
def self.find_by_name_or_id(name, user)
|
|
name_or_id_matches(name, user).first
|
|
end
|
|
|
|
def self.find_by_name_or_id!(name, user)
|
|
find_by_name_or_id(name, user) or raise ActiveRecord::RecordNotFound
|
|
end
|
|
|
|
def pretty_name
|
|
name&.tr("_", " ")
|
|
end
|
|
|
|
def posts
|
|
favgroup_posts = FavoriteGroup.where(id: id).joins("CROSS JOIN unnest(favorite_groups.post_ids) WITH ORDINALITY AS row(post_id, favgroup_index)").select(:post_id, :favgroup_index)
|
|
Post.joins("JOIN (#{favgroup_posts.to_sql}) favgroup_posts ON favgroup_posts.post_id = posts.id").order("favgroup_posts.favgroup_index ASC")
|
|
end
|
|
|
|
def add(post)
|
|
with_lock do
|
|
update(post_ids: post_ids + [post.id])
|
|
end
|
|
end
|
|
|
|
def remove(post)
|
|
with_lock do
|
|
update(post_ids: post_ids - [post.id])
|
|
end
|
|
end
|
|
|
|
def post_count
|
|
post_ids.size
|
|
end
|
|
|
|
def first_post?(post_id)
|
|
post_id == post_ids.first
|
|
end
|
|
|
|
def last_post?(post_id)
|
|
post_id == post_ids.last
|
|
end
|
|
|
|
def previous_post_id(post_id)
|
|
return nil if first_post?(post_id) || !contains?(post_id)
|
|
|
|
n = post_ids.index(post_id) - 1
|
|
post_ids[n]
|
|
end
|
|
|
|
def next_post_id(post_id)
|
|
return nil if last_post?(post_id) || !contains?(post_id)
|
|
|
|
n = post_ids.index(post_id) + 1
|
|
post_ids[n]
|
|
end
|
|
|
|
def last_page
|
|
(post_count / CurrentUser.user.per_page.to_f).ceil
|
|
end
|
|
|
|
def contains?(post_id)
|
|
post_ids.include?(post_id)
|
|
end
|
|
|
|
def is_private=(value)
|
|
self.is_public = !ActiveModel::Type::Boolean.new.cast(value)
|
|
end
|
|
|
|
def is_private
|
|
!is_public?
|
|
end
|
|
|
|
def is_private?
|
|
!is_public?
|
|
end
|
|
|
|
def self.available_includes
|
|
[:creator]
|
|
end
|
|
end
|