Merge branch 'master' into minor_fix

This commit is contained in:
evazion
2021-01-04 01:30:28 -06:00
committed by GitHub
243 changed files with 4897 additions and 2446 deletions

View File

@@ -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",

View 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

View File

@@ -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

View 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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -1,3 +1,4 @@
require "danbooru/http/application_client"
require "danbooru/http/html_adapter"
require "danbooru/http/xml_adapter"
require "danbooru/http/cache"

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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
View 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

View 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

View File

@@ -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

View File

@@ -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>")

View File

@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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