Merge branch 'master' into minor_fix
This commit is contained in:
@@ -8,6 +8,7 @@ module ArtistFinder
|
||||
"www.artstation.com", # http://www.artstation.com/serafleur/
|
||||
%r{cdn[ab]?\.artstation\.com/p/assets/images/images}i, # https://cdna.artstation.com/p/assets/images/images/001/658/068/large/yang-waterkuma-b402.jpg?1450269769
|
||||
"ask.fm", # http://ask.fm/mikuroko_396
|
||||
"baraag.net",
|
||||
"bcyimg.com",
|
||||
"bcyimg.com/drawer", # https://img9.bcyimg.com/drawer/32360/post/178vu/46229ec06e8111e79558c1b725ebc9e6.jpg
|
||||
"bcy.net",
|
||||
@@ -60,6 +61,7 @@ module ArtistFinder
|
||||
"iwara.tv/users", # http://ecchi.iwara.tv/users/marumega
|
||||
"kym-cdn.com",
|
||||
"livedoor.blogimg.jp",
|
||||
"blog.livedoor.jp", # http://blog.livedoor.jp/ac370ml
|
||||
"monappy.jp",
|
||||
"monappy.jp/u", # https://monappy.jp/u/abara_bone
|
||||
"mstdn.jp", # https://mstdn.jp/@oneb
|
||||
@@ -83,8 +85,8 @@ module ArtistFinder
|
||||
"pixiv.net", # https://www.pixiv.net/member.php?id=10442390
|
||||
"pixiv.net/stacc", # https://www.pixiv.net/stacc/aaaninja2013
|
||||
"pixiv.net/fanbox/creator", # https://www.pixiv.net/fanbox/creator/310630
|
||||
"pixiv.net/users", # https://www.pixiv.net/users/555603
|
||||
"pixiv.net/en/users", # https://www.pixiv.net/en/users/555603
|
||||
%r{pixiv.net/(?:en/)?users}i, # https://www.pixiv.net/users/555603
|
||||
%r{pixiv.net/(?:en/)?artworks}i, # https://www.pixiv.net/en/artworks/85241178
|
||||
"i.pximg.net",
|
||||
"plurk.com", # http://www.plurk.com/a1amorea1a1
|
||||
"privatter.net",
|
||||
|
||||
258
app/logical/autocomplete_service.rb
Normal file
258
app/logical/autocomplete_service.rb
Normal file
@@ -0,0 +1,258 @@
|
||||
class AutocompleteService
|
||||
extend Memoist
|
||||
|
||||
POST_STATUSES = %w[active deleted pending flagged appealed banned modqueue unmoderated]
|
||||
|
||||
STATIC_METATAGS = {
|
||||
status: %w[any] + POST_STATUSES,
|
||||
child: %w[any none] + POST_STATUSES,
|
||||
parent: %w[any none] + POST_STATUSES,
|
||||
rating: %w[safe questionable explicit],
|
||||
locked: %w[rating note status],
|
||||
embedded: %w[true false],
|
||||
filetype: %w[jpg png gif swf zip webm mp4],
|
||||
commentary: %w[true false translated untranslated],
|
||||
disapproved: PostDisapproval::REASONS,
|
||||
order: PostQueryBuilder::ORDER_METATAGS
|
||||
}
|
||||
|
||||
attr_reader :query, :type, :limit, :current_user
|
||||
|
||||
def initialize(query, type, current_user: User.anonymous, limit: 10)
|
||||
@query = query.to_s
|
||||
@type = type.to_s.to_sym
|
||||
@current_user = current_user
|
||||
@limit = limit
|
||||
end
|
||||
|
||||
def autocomplete_results
|
||||
case type
|
||||
when :tag_query
|
||||
autocomplete_tag_query(query)
|
||||
when :tag
|
||||
autocomplete_tag(query)
|
||||
when :artist
|
||||
autocomplete_artist(query)
|
||||
when :wiki_page
|
||||
autocomplete_wiki_page(query)
|
||||
when :user
|
||||
autocomplete_user(query)
|
||||
when :mention
|
||||
autocomplete_mention(query)
|
||||
when :pool
|
||||
autocomplete_pool(query)
|
||||
when :favorite_group
|
||||
autocomplete_favorite_group(query)
|
||||
when :saved_search_label
|
||||
autocomplete_saved_search_label(query)
|
||||
when :opensearch
|
||||
autocomplete_opensearch(query)
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def autocomplete_tag_query(string)
|
||||
term = PostQueryBuilder.new(string).terms.first
|
||||
return [] if term.nil?
|
||||
|
||||
case term.type
|
||||
when :tag
|
||||
autocomplete_tag(term.name)
|
||||
when :metatag
|
||||
autocomplete_metatag(term.name, term.value)
|
||||
end
|
||||
end
|
||||
|
||||
def autocomplete_tag(string)
|
||||
if string.starts_with?("/")
|
||||
string = string + "*" unless string.include?("*")
|
||||
|
||||
results = tag_matches(string)
|
||||
results += tag_abbreviation_matches(string)
|
||||
results = results.sort_by do |r|
|
||||
[r[:type] == "tag-alias" ? 0 : 1, r[:antecedent].to_s.size, -r[:post_count]]
|
||||
end
|
||||
|
||||
results = results.uniq { |r| r[:value] }.take(limit)
|
||||
elsif string.include?("*")
|
||||
results = tag_matches(string)
|
||||
results = tag_other_name_matches(string) if results.blank?
|
||||
else
|
||||
string += "*"
|
||||
results = tag_matches(string)
|
||||
results = tag_other_name_matches(string) if results.blank?
|
||||
results = tag_autocorrect_matches(string) if results.blank?
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
def tag_matches(string)
|
||||
return [] if string =~ /[^[:ascii:]]/
|
||||
|
||||
name_matches = Tag.nonempty.name_matches(string).order(post_count: :desc).limit(limit)
|
||||
alias_matches = Tag.nonempty.alias_matches(string).order(post_count: :desc).limit(limit)
|
||||
union = "((#{name_matches.to_sql}) UNION (#{alias_matches.to_sql})) AS tags"
|
||||
tags = Tag.from(union).order(post_count: :desc).limit(limit).includes(:consequent_aliases)
|
||||
|
||||
tags.map do |tag|
|
||||
antecedent = tag.tag_alias_for_pattern(string)&.antecedent_name
|
||||
type = antecedent.present? ? "tag-alias" : "tag"
|
||||
{ type: type, label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: antecedent }
|
||||
end
|
||||
end
|
||||
|
||||
def tag_abbreviation_matches(string)
|
||||
tags = Tag.nonempty.abbreviation_matches(string).order(post_count: :desc).limit(limit)
|
||||
|
||||
tags.map do |tag|
|
||||
{ type: "tag-abbreviation", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: "/" + tag.abbreviation }
|
||||
end
|
||||
end
|
||||
|
||||
def tag_autocorrect_matches(string)
|
||||
string = string.delete("*")
|
||||
tags = Tag.nonempty.autocorrect_matches(string).limit(limit)
|
||||
|
||||
tags.map do |tag|
|
||||
{ type: "tag-autocorrect", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: string }
|
||||
end
|
||||
end
|
||||
|
||||
def tag_other_name_matches(string)
|
||||
return [] unless string =~ /[^[:ascii:]]/
|
||||
|
||||
artists = Artist.undeleted.any_other_name_like(string)
|
||||
wikis = WikiPage.undeleted.other_names_match(string)
|
||||
tags = Tag.where(name: wikis.select(:title)).or(Tag.where(name: artists.select(:name)))
|
||||
tags = tags.nonempty.order(post_count: :desc).limit(limit).includes(:wiki_page, :artist)
|
||||
|
||||
tags.map do |tag|
|
||||
other_names = tag.artist&.other_names.to_a + tag.wiki_page&.other_names.to_a
|
||||
antecedent = other_names.find { |other_name| other_name.ilike?(string) }
|
||||
{ type: "tag-other-name", label: tag.pretty_name, value: tag.name, category: tag.category, post_count: tag.post_count, antecedent: antecedent }
|
||||
end
|
||||
end
|
||||
|
||||
def autocomplete_metatag(metatag, value)
|
||||
results = case metatag.to_sym
|
||||
when :user, :approver, :commenter, :comm, :noter, :noteupdater, :commentaryupdater,
|
||||
:artcomm, :fav, :ordfav, :appealer, :flagger, :upvote, :downvote
|
||||
autocomplete_user(value)
|
||||
when :pool, :ordpool
|
||||
autocomplete_pool(value)
|
||||
when :favgroup, :ordfavgroup
|
||||
autocomplete_favorite_group(value)
|
||||
when :search
|
||||
autocomplete_saved_search_label(value)
|
||||
when *STATIC_METATAGS.keys
|
||||
autocomplete_static_metatag(metatag, value)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
results.map do |result|
|
||||
{ **result, value: metatag + ":" + result[:value] }
|
||||
end
|
||||
end
|
||||
|
||||
def autocomplete_static_metatag(metatag, value)
|
||||
values = STATIC_METATAGS[metatag.to_sym]
|
||||
results = values.select { |v| v.starts_with?(value) }.sort.take(limit)
|
||||
|
||||
results.map do |v|
|
||||
{ label: metatag + ":" + v, value: v }
|
||||
end
|
||||
end
|
||||
|
||||
def autocomplete_pool(string)
|
||||
string = "*" + string + "*" unless string.include?("*")
|
||||
pools = Pool.undeleted.name_matches(string).search(order: "post_count").limit(limit)
|
||||
|
||||
pools.map do |pool|
|
||||
{ type: "pool", label: pool.pretty_name, value: pool.name, post_count: pool.post_count, category: pool.category }
|
||||
end
|
||||
end
|
||||
|
||||
def autocomplete_favorite_group(string)
|
||||
string = "*" + string + "*" unless string.include?("*")
|
||||
favgroups = FavoriteGroup.visible(current_user).where(creator: current_user).name_matches(string).search(order: "post_count").limit(limit)
|
||||
|
||||
favgroups.map do |favgroup|
|
||||
{ label: favgroup.pretty_name, value: favgroup.name, post_count: favgroup.post_count }
|
||||
end
|
||||
end
|
||||
|
||||
def autocomplete_saved_search_label(string)
|
||||
string = "*" + string + "*" unless string.include?("*")
|
||||
labels = current_user.saved_searches.labels_like(string).take(limit)
|
||||
|
||||
labels.map do |label|
|
||||
{ label: label.tr("_", " "), value: label }
|
||||
end
|
||||
end
|
||||
|
||||
def autocomplete_artist(string)
|
||||
string = string + "*" unless string.include?("*")
|
||||
artists = Artist.undeleted.name_matches(string).search(order: "post_count").limit(limit)
|
||||
|
||||
artists.map do |artist|
|
||||
{ type: "tag", label: artist.pretty_name, value: artist.name, category: Tag.categories.artist }
|
||||
end
|
||||
end
|
||||
|
||||
def autocomplete_wiki_page(string)
|
||||
string = string + "*" unless string.include?("*")
|
||||
wiki_pages = WikiPage.undeleted.title_matches(string).search(order: "post_count").limit(limit)
|
||||
|
||||
wiki_pages.map do |wiki_page|
|
||||
{ type: "tag", label: wiki_page.pretty_title, value: wiki_page.title, category: wiki_page.tag&.category }
|
||||
end
|
||||
end
|
||||
|
||||
def autocomplete_user(string)
|
||||
string = string + "*" unless string.include?("*")
|
||||
users = User.search(name_matches: string, current_user_first: true, order: "post_upload_count").limit(limit)
|
||||
|
||||
users.map do |user|
|
||||
{ type: "user", label: user.pretty_name, value: user.name, level: user.level_string }
|
||||
end
|
||||
end
|
||||
|
||||
def autocomplete_mention(string)
|
||||
autocomplete_user(string).map do |result|
|
||||
{ **result, value: "@" + result[:value] }
|
||||
end
|
||||
end
|
||||
|
||||
def autocomplete_opensearch(string)
|
||||
results = autocomplete_tag(string).map { |result| result[:value] }
|
||||
[query, results]
|
||||
end
|
||||
|
||||
def cache_duration
|
||||
if autocomplete_results.size == limit
|
||||
24.hours
|
||||
else
|
||||
1.hour
|
||||
end
|
||||
end
|
||||
|
||||
# Queries that don't depend on the current user are safe to cache publicly.
|
||||
def cache_publicly?
|
||||
if type == :tag_query && parsed_search&.type == :tag
|
||||
true
|
||||
elsif type.in?(%i[tag artist wiki_page pool opensearch])
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def parsed_search
|
||||
PostQueryBuilder.new(query).terms.first
|
||||
end
|
||||
|
||||
memoize :autocomplete_results
|
||||
end
|
||||
@@ -1,5 +1,11 @@
|
||||
class BulkUpdateRequestProcessor
|
||||
# Maximum tag size allowed by the rename command before an alias must be used.
|
||||
MAXIMUM_RENAME_COUNT = 200
|
||||
|
||||
# Maximum size of artist tags movable by builders.
|
||||
MAXIMUM_BUILDER_MOVE_COUNT = 200
|
||||
|
||||
# Maximum number of lines a BUR may have.
|
||||
MAXIMUM_SCRIPT_LENGTH = 100
|
||||
|
||||
include ActiveModel::Validations
|
||||
@@ -55,20 +61,20 @@ class BulkUpdateRequestProcessor
|
||||
tag_alias = TagAlias.new(creator: User.system, antecedent_name: args[0], consequent_name: args[1])
|
||||
tag_alias.save(context: validation_context)
|
||||
if tag_alias.errors.present?
|
||||
errors[:base] << "Can't create alias #{tag_alias.antecedent_name} -> #{tag_alias.consequent_name} (#{tag_alias.errors.full_messages.join("; ")})"
|
||||
errors.add(:base, "Can't create alias #{tag_alias.antecedent_name} -> #{tag_alias.consequent_name} (#{tag_alias.errors.full_messages.join("; ")})")
|
||||
end
|
||||
|
||||
when :create_implication
|
||||
tag_implication = TagImplication.new(creator: User.system, antecedent_name: args[0], consequent_name: args[1], status: "active")
|
||||
tag_implication.save(context: validation_context)
|
||||
if tag_implication.errors.present?
|
||||
errors[:base] << "Can't create implication #{tag_implication.antecedent_name} -> #{tag_implication.consequent_name} (#{tag_implication.errors.full_messages.join("; ")})"
|
||||
errors.add(:base, "Can't create implication #{tag_implication.antecedent_name} -> #{tag_implication.consequent_name} (#{tag_implication.errors.full_messages.join("; ")})")
|
||||
end
|
||||
|
||||
when :remove_alias
|
||||
tag_alias = TagAlias.active.find_by(antecedent_name: args[0], consequent_name: args[1])
|
||||
if tag_alias.nil?
|
||||
errors[:base] << "Can't remove alias #{args[0]} -> #{args[1]} (alias doesn't exist)"
|
||||
errors.add(:base, "Can't remove alias #{args[0]} -> #{args[1]} (alias doesn't exist)")
|
||||
else
|
||||
tag_alias.update(status: "deleted")
|
||||
end
|
||||
@@ -76,7 +82,7 @@ class BulkUpdateRequestProcessor
|
||||
when :remove_implication
|
||||
tag_implication = TagImplication.active.find_by(antecedent_name: args[0], consequent_name: args[1])
|
||||
if tag_implication.nil?
|
||||
errors[:base] << "Can't remove implication #{args[0]} -> #{args[1]} (implication doesn't exist)"
|
||||
errors.add(:base, "Can't remove implication #{args[0]} -> #{args[1]} (implication doesn't exist)")
|
||||
else
|
||||
tag_implication.update(status: "deleted")
|
||||
end
|
||||
@@ -84,22 +90,22 @@ class BulkUpdateRequestProcessor
|
||||
when :change_category
|
||||
tag = Tag.find_by_name(args[0])
|
||||
if tag.nil?
|
||||
errors[:base] << "Can't change category #{args[0]} -> #{args[1]} (the '#{args[0]}' tag doesn't exist)"
|
||||
errors.add(:base, "Can't change category #{args[0]} -> #{args[1]} (the '#{args[0]}' tag doesn't exist)")
|
||||
end
|
||||
|
||||
when :rename
|
||||
tag = Tag.find_by_name(args[0])
|
||||
if tag.nil?
|
||||
errors[:base] << "Can't rename #{args[0]} -> #{args[1]} (the '#{args[0]}' tag doesn't exist)"
|
||||
errors.add(:base, "Can't rename #{args[0]} -> #{args[1]} (the '#{args[0]}' tag doesn't exist)")
|
||||
elsif tag.post_count > MAXIMUM_RENAME_COUNT
|
||||
errors[:base] << "Can't rename #{args[0]} -> #{args[1]} ('#{args[0]}' has more than #{MAXIMUM_RENAME_COUNT} posts, use an alias instead)"
|
||||
errors.add(:base, "Can't rename #{args[0]} -> #{args[1]} ('#{args[0]}' has more than #{MAXIMUM_RENAME_COUNT} posts, use an alias instead)")
|
||||
end
|
||||
|
||||
when :mass_update, :nuke
|
||||
# okay
|
||||
|
||||
when :invalid_line
|
||||
errors[:base] << "Invalid line: #{args[0]}"
|
||||
errors.add(:base, "Invalid line: #{args[0]}")
|
||||
|
||||
else
|
||||
# should never happen
|
||||
@@ -113,7 +119,7 @@ class BulkUpdateRequestProcessor
|
||||
|
||||
def validate_script_length
|
||||
if commands.size > MAXIMUM_SCRIPT_LENGTH
|
||||
errors[:base] << "Bulk update request is too long (maximum size: #{MAXIMUM_SCRIPT_LENGTH} lines). Split your request into smaller chunks and try again."
|
||||
errors.add(:base, "Bulk update request is too long (maximum size: #{MAXIMUM_SCRIPT_LENGTH} lines). Split your request into smaller chunks and try again.")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -212,11 +218,18 @@ class BulkUpdateRequestProcessor
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
# Tag move is allowed if:
|
||||
#
|
||||
# * The antecedent tag is an artist tag.
|
||||
# * The consequent_tag is a nonexistent tag, an empty tag (of any type), or an artist tag.
|
||||
# * Both tags have less than 200 posts.
|
||||
def self.is_tag_move_allowed?(antecedent_name, consequent_name)
|
||||
antecedent_tag = Tag.find_by_name(Tag.normalize_name(antecedent_name))
|
||||
consequent_tag = Tag.find_by_name(Tag.normalize_name(consequent_name))
|
||||
|
||||
(antecedent_tag.blank? || antecedent_tag.empty? || (antecedent_tag.artist? && antecedent_tag.post_count <= 100)) &&
|
||||
(consequent_tag.blank? || consequent_tag.empty? || (consequent_tag.artist? && consequent_tag.post_count <= 100))
|
||||
antecedent_allowed = antecedent_tag.present? && antecedent_tag.artist? && antecedent_tag.post_count < MAXIMUM_BUILDER_MOVE_COUNT
|
||||
consequent_allowed = consequent_tag.nil? || consequent_tag.empty? || (consequent_tag.artist? && consequent_tag.post_count < MAXIMUM_BUILDER_MOVE_COUNT)
|
||||
|
||||
antecedent_allowed && consequent_allowed
|
||||
end
|
||||
end
|
||||
|
||||
18
app/logical/concerns/normalizable.rb
Normal file
18
app/logical/concerns/normalizable.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
module Normalizable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def normalize(attribute, method_name)
|
||||
define_method("#{attribute}=") do |value|
|
||||
normalized_value = self.class.send(method_name, value)
|
||||
super(normalized_value)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_text(text)
|
||||
text.unicode_normalize(:nfc).normalize_whitespace.strip
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,12 +10,13 @@ module Searchable
|
||||
1 + params.values.map { |v| parameter_hash?(v) ? parameter_depth(v) : 1 }.max
|
||||
end
|
||||
|
||||
def negate(kind = :nand)
|
||||
unscoped.where(all.where_clause.invert(kind).ast)
|
||||
def negate_relation
|
||||
unscoped.where(all.where_clause.invert.ast)
|
||||
end
|
||||
|
||||
# XXX hacky method to AND two relations together.
|
||||
def and(relation)
|
||||
# XXX Replace with ActiveRecord#and (cf https://github.com/rails/rails/pull/39328)
|
||||
def and_relation(relation)
|
||||
q = all
|
||||
q = q.where(relation.where_clause.ast) if relation.where_clause.present?
|
||||
q = q.joins(relation.joins_values + q.joins_values) if relation.joins_values.present?
|
||||
@@ -52,7 +53,7 @@ module Searchable
|
||||
end
|
||||
|
||||
def where_iequals(attr, value)
|
||||
where_ilike(attr, value.gsub(/\\/, '\\\\').gsub(/\*/, '\*'))
|
||||
where_ilike(attr, value.escape_wildcards)
|
||||
end
|
||||
|
||||
# https://www.postgresql.org/docs/current/static/functions-matching.html#FUNCTIONS-POSIX-REGEXP
|
||||
@@ -101,16 +102,11 @@ module Searchable
|
||||
where_operator(qualified_column, *range)
|
||||
end
|
||||
|
||||
def search_boolean_attribute(attribute, params)
|
||||
return all unless params.key?(attribute)
|
||||
|
||||
value = params[attribute].to_s
|
||||
if value.truthy?
|
||||
where(attribute => true)
|
||||
elsif value.falsy?
|
||||
where(attribute => false)
|
||||
def search_boolean_attribute(attr, params)
|
||||
if params[attr].present?
|
||||
boolean_attribute_matches(attr, params[attr])
|
||||
else
|
||||
raise ArgumentError, "value must be truthy or falsy"
|
||||
all
|
||||
end
|
||||
end
|
||||
|
||||
@@ -132,6 +128,18 @@ module Searchable
|
||||
where_operator(qualified_column, *range)
|
||||
end
|
||||
|
||||
def boolean_attribute_matches(attribute, value)
|
||||
value = value.to_s
|
||||
|
||||
if value.truthy?
|
||||
where(attribute => true)
|
||||
elsif value.falsy?
|
||||
where(attribute => false)
|
||||
else
|
||||
raise ArgumentError, "value must be truthy or falsy"
|
||||
end
|
||||
end
|
||||
|
||||
def text_attribute_matches(attribute, value, index_column: nil, ts_config: "english")
|
||||
return all unless value.present?
|
||||
|
||||
@@ -182,7 +190,7 @@ module Searchable
|
||||
when :boolean
|
||||
search_boolean_attribute(name, params)
|
||||
when :integer, :datetime
|
||||
numeric_attribute_matches(name, params[name])
|
||||
search_numeric_attribute(name, params)
|
||||
when :inet
|
||||
search_inet_attribute(name, params)
|
||||
when :enum
|
||||
@@ -195,6 +203,26 @@ module Searchable
|
||||
end
|
||||
end
|
||||
|
||||
def search_numeric_attribute(attr, params)
|
||||
if params[attr].present?
|
||||
numeric_attribute_matches(attr, params[attr])
|
||||
elsif params[:"#{attr}_eq"].present?
|
||||
where_operator(attr, :eq, params[:"#{attr}_eq"])
|
||||
elsif params[:"#{attr}_not_eq"].present?
|
||||
where_operator(attr, :not_eq, params[:"#{attr}_not_eq"])
|
||||
elsif params[:"#{attr}_gt"].present?
|
||||
where_operator(attr, :gt, params[:"#{attr}_gt"])
|
||||
elsif params[:"#{attr}_gteq"].present?
|
||||
where_operator(attr, :gteq, params[:"#{attr}_gteq"])
|
||||
elsif params[:"#{attr}_lt"].present?
|
||||
where_operator(attr, :lt, params[:"#{attr}_lt"])
|
||||
elsif params[:"#{attr}_lteq"].present?
|
||||
where_operator(attr, :lteq, params[:"#{attr}_lteq"])
|
||||
else
|
||||
all
|
||||
end
|
||||
end
|
||||
|
||||
def search_text_attribute(attr, params)
|
||||
if params[attr].present?
|
||||
where(attr => params[attr])
|
||||
@@ -385,14 +413,6 @@ module Searchable
|
||||
where(id: ids).order(Arel.sql(order_clause.join(', ')))
|
||||
end
|
||||
|
||||
def search(params = {})
|
||||
params ||= {}
|
||||
|
||||
default_attributes = (attribute_names.map(&:to_sym) & %i[id created_at updated_at])
|
||||
all_attributes = default_attributes + searchable_includes
|
||||
search_attributes(params, *all_attributes)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def qualified_column_for(attr)
|
||||
|
||||
@@ -40,6 +40,14 @@ class CurrentUser
|
||||
RequestStore[:current_ip_addr] = ip_addr
|
||||
end
|
||||
|
||||
def self.country
|
||||
RequestStore[:country]
|
||||
end
|
||||
|
||||
def self.country=(country)
|
||||
RequestStore[:country] = country
|
||||
end
|
||||
|
||||
def self.root_url
|
||||
RequestStore[:current_root_url] || "https://#{Danbooru.config.hostname}"
|
||||
end
|
||||
|
||||
@@ -109,11 +109,11 @@ class DText
|
||||
end
|
||||
|
||||
if obj.is_approved?
|
||||
"The \"bulk update request ##{obj.id}\":/bulk_update_requests/#{obj.id} has been approved by <@#{obj.approver.name}>.\n\n#{embedded_script}"
|
||||
"The \"bulk update request ##{obj.id}\":#{Routes.bulk_update_request_path(obj)} has been approved by <@#{obj.approver.name}>.\n\n#{embedded_script}"
|
||||
elsif obj.is_pending?
|
||||
"The \"bulk update request ##{obj.id}\":/bulk_update_requests/#{obj.id} is pending approval.\n\n#{embedded_script}"
|
||||
"The \"bulk update request ##{obj.id}\":#{Routes.bulk_update_request_path(obj)} is pending approval.\n\n#{embedded_script}"
|
||||
elsif obj.is_rejected?
|
||||
"The \"bulk update request ##{obj.id}\":/bulk_update_requests/#{obj.id} has been rejected.\n\n#{embedded_script}"
|
||||
"The \"bulk update request ##{obj.id}\":#{Routes.bulk_update_request_path(obj)} has been rejected.\n\n#{embedded_script}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
require "danbooru/http/application_client"
|
||||
require "danbooru/http/html_adapter"
|
||||
require "danbooru/http/xml_adapter"
|
||||
require "danbooru/http/cache"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class DanbooruLogger
|
||||
HEADERS = %w[referer sec-fetch-dest sec-fetch-mode sec-fetch-site sec-fetch-user]
|
||||
|
||||
def self.info(message, params = {})
|
||||
Rails.logger.info(message)
|
||||
|
||||
@@ -22,25 +24,52 @@ class DanbooruLogger
|
||||
end
|
||||
|
||||
def self.add_session_attributes(request, session, user)
|
||||
request_params = request.parameters.with_indifferent_access.except(:controller, :action)
|
||||
session_params = session.to_h.with_indifferent_access.slice(:session_id, :started_at)
|
||||
user_params = { id: user&.id, name: user&.name, level: user&.level_string, ip: request.remote_ip, safe_mode: CurrentUser.safe_mode? }
|
||||
add_attributes("request", { path: request.path })
|
||||
add_attributes("request.headers", header_params(request))
|
||||
add_attributes("request.params", request_params(request))
|
||||
add_attributes("session.params", session_params(session))
|
||||
add_attributes("user", user_params(request, user))
|
||||
end
|
||||
|
||||
add_attributes("request.params", request_params)
|
||||
add_attributes("session.params", session_params)
|
||||
add_attributes("user", user_params)
|
||||
def self.header_params(request)
|
||||
headers = request.headers.to_h.select { |header, value| header.match?(/\AHTTP_/) }
|
||||
headers = headers.transform_keys { |header| header.delete_prefix("HTTP_").downcase }
|
||||
headers = headers.select { |header, value| header.in?(HEADERS) }
|
||||
headers
|
||||
end
|
||||
|
||||
def self.request_params(request)
|
||||
request.parameters.with_indifferent_access.except(:controller, :action)
|
||||
end
|
||||
|
||||
def self.session_params(session)
|
||||
session.to_h.with_indifferent_access.slice(:session_id, :started_at)
|
||||
end
|
||||
|
||||
def self.user_params(request, user)
|
||||
{
|
||||
id: user&.id,
|
||||
name: user&.name,
|
||||
level: user&.level_string,
|
||||
ip: request.remote_ip,
|
||||
country: CurrentUser.country,
|
||||
safe_mode: CurrentUser.safe_mode?
|
||||
}
|
||||
end
|
||||
|
||||
def self.add_attributes(prefix, hash)
|
||||
return unless defined?(::NewRelic)
|
||||
|
||||
attributes = flatten_hash(hash).transform_keys { |key| "#{prefix}.#{key}" }
|
||||
attributes.delete_if { |key, value| key.end_with?(*Rails.application.config.filter_parameters.map(&:to_s)) }
|
||||
::NewRelic::Agent.add_custom_attributes(attributes)
|
||||
add_custom_attributes(attributes)
|
||||
end
|
||||
|
||||
private_class_method
|
||||
|
||||
def self.add_custom_attributes(attributes)
|
||||
return unless defined?(::NewRelic)
|
||||
::NewRelic::Agent.add_custom_attributes(attributes)
|
||||
end
|
||||
|
||||
# flatten_hash({ foo: { bar: { baz: 42 } } })
|
||||
# => { "foo.bar.baz" => 42 }
|
||||
def self.flatten_hash(hash)
|
||||
|
||||
@@ -16,7 +16,7 @@ class DtextInput < SimpleForm::Inputs::Base
|
||||
t = template
|
||||
merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
|
||||
|
||||
t.tag.div(class: "dtext-previewable") do
|
||||
t.tag.div(class: "dtext-previewable", spellcheck: true) do
|
||||
if options[:inline]
|
||||
t.concat @builder.text_field(attribute_name, merged_input_options)
|
||||
else
|
||||
|
||||
@@ -3,6 +3,9 @@ require 'resolv'
|
||||
module EmailValidator
|
||||
module_function
|
||||
|
||||
# https://www.regular-expressions.info/email.html
|
||||
EMAIL_REGEX = /\A[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z/
|
||||
|
||||
IGNORE_DOTS = %w[gmail.com]
|
||||
IGNORE_PLUS_ADDRESSING = %w[gmail.com hotmail.com outlook.com live.com]
|
||||
IGNORE_MINUS_ADDRESSING = %w[yahoo.com]
|
||||
@@ -80,10 +83,17 @@ module EmailValidator
|
||||
"#{name}@#{domain}"
|
||||
end
|
||||
|
||||
def nondisposable?(address)
|
||||
return true if Danbooru.config.email_domain_verification_list.blank?
|
||||
def is_valid?(address)
|
||||
address.match?(EMAIL_REGEX)
|
||||
end
|
||||
|
||||
def is_restricted?(address)
|
||||
return false if Danbooru.config.email_domain_verification_list.blank?
|
||||
|
||||
domain = Mail::Address.new(address).domain
|
||||
domain.in?(Danbooru.config.email_domain_verification_list.to_a)
|
||||
!domain.in?(Danbooru.config.email_domain_verification_list.to_a)
|
||||
rescue Mail::Field::IncompleteParseError
|
||||
true
|
||||
end
|
||||
|
||||
def undeliverable?(to_address, from_address: Danbooru.config.contact_email, timeout: 3)
|
||||
|
||||
@@ -3,6 +3,9 @@ require "strscan"
|
||||
class PostQueryBuilder
|
||||
extend Memoist
|
||||
|
||||
# How many tags a `blah*` search should match.
|
||||
MAX_WILDCARD_TAGS = 100
|
||||
|
||||
COUNT_METATAGS = %w[
|
||||
comment_count deleted_comment_count active_comment_count
|
||||
note_count deleted_note_count active_note_count
|
||||
@@ -77,9 +80,9 @@ class PostQueryBuilder
|
||||
optional_tags = optional_tags.map(&:name)
|
||||
required_tags = required_tags.map(&:name)
|
||||
|
||||
negated_tags += negated_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name) }
|
||||
optional_tags += optional_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name) }
|
||||
optional_tags += required_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name) }
|
||||
negated_tags += negated_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name).limit(MAX_WILDCARD_TAGS).pluck(:name) }
|
||||
optional_tags += optional_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name).limit(MAX_WILDCARD_TAGS).pluck(:name) }
|
||||
optional_tags += required_wildcard_tags.flat_map { |tag| Tag.wildcard_matches(tag.name).limit(MAX_WILDCARD_TAGS).pluck(:name) }
|
||||
|
||||
tsquery << "!(#{negated_tags.sort.uniq.map(&:to_escaped_for_tsquery).join(" | ")})" if negated_tags.present?
|
||||
tsquery << "(#{optional_tags.sort.uniq.map(&:to_escaped_for_tsquery).join(" | ")})" if optional_tags.present?
|
||||
@@ -92,8 +95,8 @@ class PostQueryBuilder
|
||||
def metatags_match(metatags, relation)
|
||||
metatags.each do |metatag|
|
||||
clause = metatag_matches(metatag.name, metatag.value, quoted: metatag.quoted)
|
||||
clause = clause.negate if metatag.negated
|
||||
relation = relation.and(clause)
|
||||
clause = clause.negate_relation if metatag.negated
|
||||
relation = relation.and_relation(clause)
|
||||
end
|
||||
|
||||
relation
|
||||
@@ -390,7 +393,8 @@ class PostQueryBuilder
|
||||
favuser = User.find_by_name(username)
|
||||
|
||||
if favuser.present? && Pundit.policy!([current_user, nil], favuser).can_see_favorites?
|
||||
tags_include("fav:#{favuser.id}")
|
||||
favorites = Favorite.from("favorites_#{favuser.id % 100} AS favorites").where(user: favuser)
|
||||
Post.where(id: favorites.select(:post_id))
|
||||
else
|
||||
Post.none
|
||||
end
|
||||
@@ -399,8 +403,8 @@ class PostQueryBuilder
|
||||
def ordfav_matches(username)
|
||||
user = User.find_by_name(username)
|
||||
|
||||
if user.present?
|
||||
favorites_include(username).joins(:favorites).merge(Favorite.for_user(user.id)).order("favorites.id DESC")
|
||||
if user.present? && Pundit.policy!([current_user, nil], user).can_see_favorites?
|
||||
Post.joins(:favorites).merge(Favorite.for_user(user.id)).order("favorites.id DESC")
|
||||
else
|
||||
Post.none
|
||||
end
|
||||
@@ -985,6 +989,11 @@ class PostQueryBuilder
|
||||
def is_wildcard_search?
|
||||
is_single_tag? && tags.first.wildcard
|
||||
end
|
||||
|
||||
def simple_tag
|
||||
return nil if !is_simple_tag?
|
||||
Tag.find_by_name(tags.first.name)
|
||||
end
|
||||
end
|
||||
|
||||
memoize :split_query, :normalized_query
|
||||
|
||||
@@ -187,16 +187,19 @@ module PostSets
|
||||
RelatedTagCalculator.frequent_tags_for_post_array(posts).take(MAX_SIDEBAR_TAGS)
|
||||
end
|
||||
|
||||
# Wildcard searches can show up to 100 tags in the sidebar, not 25,
|
||||
# because that's how many tags the search itself will use.
|
||||
def wildcard_tags
|
||||
Tag.wildcard_matches(tag_string)
|
||||
Tag.wildcard_matches(tag_string).limit(PostQueryBuilder::MAX_WILDCARD_TAGS).pluck(:name)
|
||||
end
|
||||
|
||||
def saved_search_tags
|
||||
["search:all"] + SavedSearch.labels_for(CurrentUser.user.id).map {|x| "search:#{x}"}
|
||||
searches = ["search:all"] + SavedSearch.labels_for(CurrentUser.user.id).map {|x| "search:#{x}"}
|
||||
searches.take(MAX_SIDEBAR_TAGS)
|
||||
end
|
||||
|
||||
def tag_set_presenter
|
||||
@tag_set_presenter ||= TagSetPresenter.new(related_tags.take(MAX_SIDEBAR_TAGS))
|
||||
@tag_set_presenter ||= TagSetPresenter.new(related_tags)
|
||||
end
|
||||
|
||||
def tag_list_html(**options)
|
||||
|
||||
@@ -71,9 +71,12 @@ class RelatedTagQuery
|
||||
end
|
||||
|
||||
def other_wiki_pages
|
||||
if Tag.category_for(query) == Tag.categories.copyright
|
||||
tag = post_query.simple_tag
|
||||
return [] if tag.nil?
|
||||
|
||||
if tag.copyright?
|
||||
copyright_other_wiki_pages
|
||||
elsif Tag.category_for(query) == Tag.categories.general
|
||||
elsif tag.general?
|
||||
general_other_wiki_pages
|
||||
else
|
||||
[]
|
||||
|
||||
11
app/logical/routes.rb
Normal file
11
app/logical/routes.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
# Allow Rails URL helpers to be used outside of views.
|
||||
# Example: Routes.posts_path(tags: "touhou") => /posts?tags=touhou
|
||||
|
||||
class Routes
|
||||
include Singleton
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
class << self
|
||||
delegate_missing_to :instance
|
||||
end
|
||||
end
|
||||
109
app/logical/server_status.rb
Normal file
109
app/logical/server_status.rb
Normal file
@@ -0,0 +1,109 @@
|
||||
class ServerStatus
|
||||
extend Memoist
|
||||
include ActiveModel::Serializers::JSON
|
||||
include ActiveModel::Serializers::Xml
|
||||
|
||||
def serializable_hash(*options)
|
||||
{
|
||||
status: {
|
||||
hostname: hostname,
|
||||
uptime: uptime,
|
||||
loadavg: loadavg,
|
||||
ruby_version: RUBY_VERSION,
|
||||
distro_version: distro_version,
|
||||
kernel_version: kernel_version,
|
||||
libvips_version: libvips_version,
|
||||
ffmpeg_version: ffmpeg_version,
|
||||
mkvmerge_version: mkvmerge_version,
|
||||
redis_version: redis_version,
|
||||
postgres_version: postgres_version,
|
||||
},
|
||||
postgres: {
|
||||
connection_stats: postgres_connection_stats,
|
||||
},
|
||||
redis: {
|
||||
info: redis_info,
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
concerning :InfoMethods do
|
||||
def hostname
|
||||
Socket.gethostname
|
||||
end
|
||||
|
||||
def uptime
|
||||
seconds = File.read("/proc/uptime").split[0].to_f
|
||||
"#{seconds.seconds.in_days.round} days"
|
||||
end
|
||||
|
||||
def loadavg
|
||||
File.read("/proc/loadavg").chomp
|
||||
end
|
||||
|
||||
def kernel_version
|
||||
File.read("/proc/version").chomp
|
||||
end
|
||||
|
||||
def distro_version
|
||||
`. /etc/os-release; echo "$NAME $VERSION"`.chomp
|
||||
end
|
||||
|
||||
def libvips_version
|
||||
Vips::LIBRARY_VERSION
|
||||
end
|
||||
|
||||
def ffmpeg_version
|
||||
version = `ffmpeg -version`
|
||||
version[/ffmpeg version ([0-9.]+)/, 1]
|
||||
end
|
||||
|
||||
def mkvmerge_version
|
||||
`mkvmerge --version`.chomp
|
||||
end
|
||||
end
|
||||
|
||||
concerning :RedisMethods do
|
||||
def redis_info
|
||||
return {} if Rails.cache.try(:redis).nil?
|
||||
Rails.cache.redis.info
|
||||
end
|
||||
|
||||
def redis_used_memory
|
||||
redis_info["used_memory_rss_human"]
|
||||
end
|
||||
|
||||
def redis_version
|
||||
redis_info["redis_version"]
|
||||
end
|
||||
end
|
||||
|
||||
concerning :PostgresMethods do
|
||||
def postgres_version
|
||||
ApplicationRecord.connection.select_value("SELECT version()")
|
||||
end
|
||||
|
||||
def postgres_active_connections
|
||||
ApplicationRecord.connection.select_value("SELECT COUNT(*) FROM pg_stat_activity WHERE state = 'active'")
|
||||
end
|
||||
|
||||
def postgres_connection_stats
|
||||
run_query("SELECT pid, state, query_start, state_change, xact_start, backend_start, backend_type FROM pg_stat_activity ORDER BY state, query_start DESC, backend_type")
|
||||
end
|
||||
|
||||
def run_query(query)
|
||||
result = ApplicationRecord.connection.select_all(query)
|
||||
serialize_result(result)
|
||||
end
|
||||
|
||||
def serialize_result(result)
|
||||
result.rows.map do |row|
|
||||
row.each_with_index.map do |col, i|
|
||||
[result.columns[i], col]
|
||||
end.to_h
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
memoize :redis_info
|
||||
end
|
||||
@@ -34,6 +34,7 @@ class SessionLoader
|
||||
update_last_logged_in_at
|
||||
update_last_ip_addr
|
||||
set_time_zone
|
||||
set_country
|
||||
set_safe_mode
|
||||
initialize_session_cookies
|
||||
CurrentUser.user.unban! if CurrentUser.user.ban_expired?
|
||||
@@ -87,7 +88,7 @@ class SessionLoader
|
||||
|
||||
def update_last_logged_in_at
|
||||
return if CurrentUser.is_anonymous?
|
||||
return if CurrentUser.last_logged_in_at && CurrentUser.last_logged_in_at > 1.week.ago
|
||||
return if CurrentUser.last_logged_in_at && CurrentUser.last_logged_in_at > 1.hour.ago
|
||||
CurrentUser.user.update_attribute(:last_logged_in_at, Time.now)
|
||||
end
|
||||
|
||||
@@ -101,6 +102,12 @@ class SessionLoader
|
||||
Time.zone = CurrentUser.user.time_zone
|
||||
end
|
||||
|
||||
# Depends on Cloudflare
|
||||
# https://support.cloudflare.com/hc/en-us/articles/200168236-Configuring-Cloudflare-IP-Geolocation
|
||||
def set_country
|
||||
CurrentUser.country = request.headers["CF-IPCountry"]
|
||||
end
|
||||
|
||||
def set_safe_mode
|
||||
safe_mode = request.host.match?(/safebooru/i) || params[:safe_mode].to_s.truthy? || CurrentUser.user.enable_safe_mode?
|
||||
CurrentUser.safe_mode = safe_mode
|
||||
|
||||
@@ -65,15 +65,15 @@ module Sources
|
||||
|
||||
text = text.gsub(%r{https?://www\.pixiv\.net/member_illust\.php\?mode=medium&illust_id=([0-9]+)}i) do |_match|
|
||||
pixiv_id = $1
|
||||
%(pixiv ##{pixiv_id} "»":[/posts?tags=pixiv:#{pixiv_id}])
|
||||
%(pixiv ##{pixiv_id} "»":[#{Routes.posts_path(tags: "pixiv:#{pixiv_id}")}])
|
||||
end
|
||||
|
||||
text = text.gsub(%r{https?://www\.pixiv\.net/member\.php\?id=([0-9]+)}i) do |_match|
|
||||
member_id = $1
|
||||
profile_url = "https://www.pixiv.net/users/#{member_id}"
|
||||
search_params = {"search[url_matches]" => profile_url}.to_param
|
||||
artist_search_url = Routes.artists_path(search: { url_matches: profile_url })
|
||||
|
||||
%("user/#{member_id}":[#{profile_url}] "»":[/artists?#{search_params}])
|
||||
%("user/#{member_id}":[#{profile_url}] "»":[#{artist_search_url}])
|
||||
end
|
||||
|
||||
text = text.gsub(/\r\n|\r|\n/, "<br>")
|
||||
|
||||
@@ -90,6 +90,10 @@ module Sources
|
||||
image_urls.map { |img| img.gsub(%r{.cn/\w+/(\w+)}, '.cn/orj360/\1') }
|
||||
end
|
||||
|
||||
def headers
|
||||
{ "Referer" => "https://weibo.com" }
|
||||
end
|
||||
|
||||
def page_url
|
||||
if api_response.present?
|
||||
artist_id = api_response["user"]["id"]
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
module TagAutocomplete
|
||||
module_function
|
||||
|
||||
PREFIX_BOUNDARIES = "(_/:;-"
|
||||
LIMIT = 10
|
||||
|
||||
Result = Struct.new(:name, :post_count, :category, :antecedent_name, :source) do
|
||||
include ActiveModel::Serializers::JSON
|
||||
include ActiveModel::Serializers::Xml
|
||||
|
||||
def attributes
|
||||
(members + [:weight]).map { |x| [x.to_s, send(x)] }.to_h
|
||||
end
|
||||
|
||||
def weight
|
||||
case source
|
||||
when :exact then 1.0
|
||||
when :prefix then 0.8
|
||||
when :alias then 0.2
|
||||
when :correct then 0.1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def search(query)
|
||||
query = Tag.normalize_name(query)
|
||||
|
||||
count_sort(
|
||||
search_exact(query, 8) +
|
||||
search_prefix(query, 4) +
|
||||
search_correct(query, 2) +
|
||||
search_aliases(query, 3)
|
||||
)
|
||||
end
|
||||
|
||||
def count_sort(words)
|
||||
words.uniq(&:name).sort_by do |x|
|
||||
x.post_count * x.weight
|
||||
end.reverse.slice(0, LIMIT)
|
||||
end
|
||||
|
||||
def search_exact(query, n = 4)
|
||||
Tag
|
||||
.where_like(:name, query + "*")
|
||||
.where("post_count > 0")
|
||||
.order("post_count desc")
|
||||
.limit(n)
|
||||
.pluck(:name, :post_count, :category)
|
||||
.map {|row| Result.new(*row, nil, :exact)}
|
||||
end
|
||||
|
||||
def search_correct(query, n = 2)
|
||||
if query.size <= 3
|
||||
return []
|
||||
end
|
||||
|
||||
Tag
|
||||
.where("name % ?", query)
|
||||
.where("abs(length(name) - ?) <= 3", query.size)
|
||||
.where_like(:name, query[0] + "*")
|
||||
.where("post_count > 0")
|
||||
.order(Arel.sql("similarity(name, #{Tag.connection.quote(query)}) DESC"))
|
||||
.limit(n)
|
||||
.pluck(:name, :post_count, :category)
|
||||
.map {|row| Result.new(*row, nil, :correct)}
|
||||
end
|
||||
|
||||
def search_prefix(query, n = 3)
|
||||
if query.size >= 5
|
||||
return []
|
||||
end
|
||||
|
||||
if query.size <= 1
|
||||
return []
|
||||
end
|
||||
|
||||
if query =~ /[-_()]/
|
||||
return []
|
||||
end
|
||||
|
||||
if query.size >= 3
|
||||
min_post_count = 0
|
||||
else
|
||||
min_post_count = 5_000
|
||||
n += 2
|
||||
end
|
||||
|
||||
regexp = "([a-z0-9])[a-z0-9']*($|[^a-z0-9']+)"
|
||||
Tag
|
||||
.where('regexp_replace(name, ?, ?, ?) like ?', regexp, '\1', 'g', query.to_escaped_for_sql_like + '%')
|
||||
.where("post_count > ?", min_post_count)
|
||||
.where("post_count > 0")
|
||||
.order("post_count desc")
|
||||
.limit(n)
|
||||
.pluck(:name, :post_count, :category)
|
||||
.map {|row| Result.new(*row, nil, :prefix)}
|
||||
end
|
||||
|
||||
def search_aliases(query, n = 10)
|
||||
wildcard_name = query + "*"
|
||||
TagAlias
|
||||
.select("tags.name, tags.post_count, tags.category, tag_aliases.antecedent_name")
|
||||
.joins("INNER JOIN tags ON tags.name = tag_aliases.consequent_name")
|
||||
.where_like(:antecedent_name, wildcard_name)
|
||||
.active
|
||||
.where_not_like("tags.name", wildcard_name)
|
||||
.where("tags.post_count > 0")
|
||||
.order("tags.post_count desc")
|
||||
.limit(n)
|
||||
.pluck(:name, :post_count, :category, :antecedent_name)
|
||||
.map {|row| Result.new(*row, :alias)}
|
||||
end
|
||||
end
|
||||
@@ -1,38 +1,40 @@
|
||||
class TagNameValidator < ActiveModel::EachValidator
|
||||
def validate_each(record, attribute, value)
|
||||
case Tag.normalize_name(value)
|
||||
value = Tag.normalize_name(value)
|
||||
|
||||
if value.size > 170
|
||||
record.errors.add(attribute, "'#{value}' cannot be more than 255 characters long")
|
||||
end
|
||||
|
||||
case value
|
||||
when /\A_*\z/
|
||||
record.errors[attribute] << "'#{value}' cannot be blank"
|
||||
record.errors.add(attribute, "'#{value}' cannot be blank")
|
||||
when /\*/
|
||||
record.errors[attribute] << "'#{value}' cannot contain asterisks ('*')"
|
||||
record.errors.add(attribute, "'#{value}' cannot contain asterisks ('*')")
|
||||
when /,/
|
||||
record.errors[attribute] << "'#{value}' cannot contain commas (',')"
|
||||
when /\A~/
|
||||
record.errors[attribute] << "'#{value}' cannot begin with a tilde ('~')"
|
||||
when /\A-/
|
||||
record.errors[attribute] << "'#{value}' cannot begin with a dash ('-')"
|
||||
when /\A_/
|
||||
record.errors[attribute] << "'#{value}' cannot begin with an underscore"
|
||||
record.errors.add(attribute, "'#{value}' cannot contain commas (',')")
|
||||
when /\A[-~_`%){}\]\/]/
|
||||
record.errors.add(attribute, "'#{value}' cannot begin with a '#{value[0]}'")
|
||||
when /_\z/
|
||||
record.errors[attribute] << "'#{value}' cannot end with an underscore"
|
||||
record.errors.add(attribute, "'#{value}' cannot end with an underscore")
|
||||
when /__/
|
||||
record.errors[attribute] << "'#{value}' cannot contain consecutive underscores"
|
||||
record.errors.add(attribute, "'#{value}' cannot contain consecutive underscores")
|
||||
when /[^[:graph:]]/
|
||||
record.errors[attribute] << "'#{value}' cannot contain non-printable characters"
|
||||
record.errors.add(attribute, "'#{value}' cannot contain non-printable characters")
|
||||
when /[^[:ascii:]]/
|
||||
record.errors[attribute] << "'#{value}' must consist of only ASCII characters"
|
||||
record.errors.add(attribute, "'#{value}' must consist of only ASCII characters")
|
||||
when /\A(#{PostQueryBuilder::METATAGS.join("|")}):(.+)\z/i
|
||||
record.errors[attribute] << "'#{value}' cannot begin with '#{$1}:'"
|
||||
record.errors.add(attribute, "'#{value}' cannot begin with '#{$1}:'")
|
||||
when /\A(#{Tag.categories.regexp}):(.+)\z/i
|
||||
record.errors[attribute] << "'#{value}' cannot begin with '#{$1}:'"
|
||||
record.errors.add(attribute, "'#{value}' cannot begin with '#{$1}:'")
|
||||
when "new", "search"
|
||||
record.errors[attribute] << "'#{value}' is a reserved name and cannot be used"
|
||||
record.errors.add(attribute, "'#{value}' is a reserved name and cannot be used")
|
||||
when /\A(.+)_\(cosplay\)\z/i
|
||||
tag_name = TagAlias.to_aliased([$1]).first
|
||||
tag = Tag.find_by_name(tag_name)
|
||||
|
||||
if tag.present? && !tag.empty? && !tag.character?
|
||||
record.errors[attribute] << "#{tag_name} must be a character tag"
|
||||
record.errors.add(attribute, "#{tag_name} must be a character tag")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,7 +11,7 @@ class UploadService
|
||||
end
|
||||
|
||||
def comment_replacement_message(post, replacement)
|
||||
%("#{replacement.creator.name}":[/users/#{replacement.creator.id}] replaced this post with a new file:\n\n#{replacement_message(post, replacement)})
|
||||
%("#{replacement.creator.name}":[#{Routes.user_path(replacement.creator)}] replaced this post with a new file:\n\n#{replacement_message(post, replacement)})
|
||||
end
|
||||
|
||||
def replacement_message(post, replacement)
|
||||
|
||||
@@ -61,11 +61,11 @@ class UserDeletion
|
||||
|
||||
def validate_deletion
|
||||
if !user.authenticate_password(password)
|
||||
errors[:base] << "Password is incorrect"
|
||||
errors.add(:base, "Password is incorrect")
|
||||
end
|
||||
|
||||
if user.level >= User::Levels::ADMIN
|
||||
errors[:base] << "Admins cannot delete their account"
|
||||
if user.is_admin?
|
||||
errors.add(:base, "Admins cannot delete their account")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,10 +2,10 @@ class UserNameValidator < ActiveModel::EachValidator
|
||||
def validate_each(rec, attr, value)
|
||||
name = value
|
||||
|
||||
rec.errors[attr] << "already exists" if User.find_by_name(name).present?
|
||||
rec.errors[attr] << "must be 2 to 100 characters long" if !name.length.between?(2, 100)
|
||||
rec.errors[attr] << "cannot have whitespace or colons" if name =~ /[[:space:]]|:/
|
||||
rec.errors[attr] << "cannot begin or end with an underscore" if name =~ /\A_|_\z/
|
||||
rec.errors[attr] << "is not allowed" if name =~ Regexp.union(Danbooru.config.user_name_blacklist)
|
||||
rec.errors.add(attr, "already exists") if User.find_by_name(name).present?
|
||||
rec.errors.add(attr, "must be 2 to 100 characters long") if !name.length.between?(2, 100)
|
||||
rec.errors.add(attr, "cannot have whitespace or colons") if name =~ /[[:space:]]|:/
|
||||
rec.errors.add(attr, "cannot begin or end with an underscore") if name =~ /\A_|_\z/
|
||||
rec.errors.add(attr, "is not allowed") if name =~ Regexp.union(Danbooru.config.user_name_blacklist)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
class UserPromotion
|
||||
attr_reader :user, :promoter, :new_level, :options, :old_can_approve_posts, :old_can_upload_free
|
||||
attr_reader :user, :promoter, :new_level, :old_can_approve_posts, :old_can_upload_free, :can_upload_free, :can_approve_posts
|
||||
|
||||
def initialize(user, promoter, new_level, options = {})
|
||||
def initialize(user, promoter, new_level, can_upload_free: nil, can_approve_posts: nil)
|
||||
@user = user
|
||||
@promoter = promoter
|
||||
@new_level = new_level
|
||||
@options = options
|
||||
@new_level = new_level.to_i
|
||||
@can_upload_free = can_upload_free
|
||||
@can_approve_posts = can_approve_posts
|
||||
end
|
||||
|
||||
def promote!
|
||||
validate
|
||||
validate!
|
||||
|
||||
@old_can_approve_posts = user.can_approve_posts?
|
||||
@old_can_upload_free = user.can_upload_free?
|
||||
|
||||
user.level = new_level
|
||||
user.can_upload_free = can_upload_free unless can_upload_free.nil?
|
||||
user.can_approve_posts = can_approve_posts unless can_approve_posts.nil?
|
||||
user.inviter = promoter
|
||||
|
||||
if options.key?(:can_approve_posts)
|
||||
user.can_approve_posts = options[:can_approve_posts]
|
||||
end
|
||||
|
||||
if options.key?(:can_upload_free)
|
||||
user.can_upload_free = options[:can_upload_free]
|
||||
end
|
||||
|
||||
user.inviter_id = promoter.id
|
||||
|
||||
create_user_feedback unless options[:is_upgrade]
|
||||
create_dmail unless options[:skip_dmail]
|
||||
create_user_feedback
|
||||
create_dmail
|
||||
create_mod_actions
|
||||
|
||||
user.save
|
||||
@@ -37,28 +31,28 @@ class UserPromotion
|
||||
|
||||
def create_mod_actions
|
||||
if old_can_approve_posts != user.can_approve_posts?
|
||||
ModAction.log("\"#{promoter.name}\":/users/#{promoter.id} changed approval privileges for \"#{user.name}\":/users/#{user.id} from #{old_can_approve_posts} to [b]#{user.can_approve_posts?}[/b]", :user_approval_privilege)
|
||||
ModAction.log("\"#{promoter.name}\":#{Routes.user_path(promoter)} changed approval privileges for \"#{user.name}\":#{Routes.user_path(user)} from #{old_can_approve_posts} to [b]#{user.can_approve_posts?}[/b]", :user_approval_privilege, promoter)
|
||||
end
|
||||
|
||||
if old_can_upload_free != user.can_upload_free?
|
||||
ModAction.log("\"#{promoter.name}\":/users/#{promoter.id} changed unlimited upload privileges for \"#{user.name}\":/users/#{user.id} from #{old_can_upload_free} to [b]#{user.can_upload_free?}[/b]", :user_upload_privilege)
|
||||
ModAction.log("\"#{promoter.name}\":#{Routes.user_path(promoter)} changed unlimited upload privileges for \"#{user.name}\":#{Routes.user_path(user)} from #{old_can_upload_free} to [b]#{user.can_upload_free?}[/b]", :user_upload_privilege, promoter)
|
||||
end
|
||||
|
||||
if user.level_changed?
|
||||
category = options[:is_upgrade] ? :user_account_upgrade : :user_level_change
|
||||
ModAction.log(%{"#{user.name}":/users/#{user.id} level changed #{user.level_string_was} -> #{user.level_string}}, category)
|
||||
ModAction.log(%{"#{user.name}":#{Routes.user_path(user)} level changed #{user.level_string_was} -> #{user.level_string}}, :user_level_change, promoter)
|
||||
end
|
||||
end
|
||||
|
||||
def validate
|
||||
# admins can do anything
|
||||
return if promoter.is_admin?
|
||||
|
||||
# can't promote/demote moderators
|
||||
raise User::PrivilegeError if user.is_moderator?
|
||||
|
||||
# can't promote to admin
|
||||
raise User::PrivilegeError if new_level.to_i >= User::Levels::ADMIN
|
||||
def validate!
|
||||
if !promoter.is_moderator?
|
||||
raise User::PrivilegeError, "You can't promote or demote other users"
|
||||
elsif promoter == user
|
||||
raise User::PrivilegeError, "You can't promote or demote yourself"
|
||||
elsif new_level >= promoter.level
|
||||
raise User::PrivilegeError, "You can't promote other users to your rank or above"
|
||||
elsif user.level >= promoter.level
|
||||
raise User::PrivilegeError, "You can't promote or demote other users at your rank or above"
|
||||
end
|
||||
end
|
||||
|
||||
def build_messages
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
class UserUpgrade
|
||||
def self.gold_price
|
||||
2000
|
||||
end
|
||||
|
||||
def self.platinum_price
|
||||
4000
|
||||
end
|
||||
|
||||
def self.upgrade_price
|
||||
2000
|
||||
end
|
||||
end
|
||||
51
app/logical/user_verifier.rb
Normal file
51
app/logical/user_verifier.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
# Checks whether a new account seems suspicious and should require email verification.
|
||||
|
||||
class UserVerifier
|
||||
attr_reader :current_user, :request
|
||||
|
||||
# current_user is the user creating the new account, not the new account itself.
|
||||
def initialize(current_user, request)
|
||||
@current_user, @request = current_user, request
|
||||
end
|
||||
|
||||
def requires_verification?
|
||||
return false if !Danbooru.config.new_user_verification?
|
||||
return false if is_local_ip?
|
||||
|
||||
# we check for IP bans first to make sure we bump the IP ban hit count
|
||||
is_ip_banned? || is_logged_in? || is_recent_signup? || is_proxy?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ip_address
|
||||
@ip_address ||= IPAddress.parse(request.remote_ip)
|
||||
end
|
||||
|
||||
def is_local_ip?
|
||||
if ip_address.ipv4?
|
||||
ip_address.loopback? || ip_address.link_local? || ip_address.private?
|
||||
elsif ip_address.ipv6?
|
||||
ip_address.loopback? || ip_address.link_local? || ip_address.unique_local?
|
||||
end
|
||||
end
|
||||
|
||||
def is_logged_in?
|
||||
!current_user.is_anonymous?
|
||||
end
|
||||
|
||||
def is_recent_signup?(age: 24.hours)
|
||||
subnet_len = ip_address.ipv4? ? 24 : 64
|
||||
subnet = "#{ip_address}/#{subnet_len}"
|
||||
|
||||
User.where("last_ip_addr <<= ?", subnet).where("created_at > ?", age.ago).exists?
|
||||
end
|
||||
|
||||
def is_ip_banned?
|
||||
IpBan.hit!(:partial, ip_address.to_s)
|
||||
end
|
||||
|
||||
def is_proxy?
|
||||
IpLookup.new(ip_address).is_proxy?
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user