Files
danbooru/app/models/application_record.rb
evazion 0bd749c306 reports: increase database timeout; add rate limits.
Increase the database timeout to 10 seconds when generating reports.
Generating reports tends to be slow, especially for things like graphing
posts over time since the beginning of Danbooru.

Does not apply to anonymous users. Users must have an account to get
higher timeouts so that we can identify users scraping reports too hard.

Also add a rate limit of 1 report per 3 seconds to limit abuse.
2022-10-21 01:04:30 -05:00

256 lines
7.9 KiB
Ruby

# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
include Deletable
include Mentionable
include Normalizable
include ArrayAttribute
include HasDtextLinks
extend HasBitFlags
extend Searchable
extend Aggregatable
concerning :PaginationMethods do
class_methods do
def paginate(*args, **options)
extending(PaginationExtension).paginate(*args, **options)
end
# Perform a search using the model's `search` method, then paginate the results.
#
# @param params [Hash] The URL request params from the user
# @param page [Integer] The page number
# @param limit [Integer] The number of posts per page
# @param count_pages [Boolean] If true, calculate the exact number of pages of
# results. If false (the default), don't calculate the exact number of pages
# of results; assume there are too many pages to count.
# @param count [Integer] the precalculated number of search results, or nil to calculate it
# @param defaults [Hash] The default params for the search
# @param current_user [User] The user performing the search
def paginated_search(params, page: params[:page], limit: params[:limit], count_pages: params[:search].present?, count: nil, defaults: {}, current_user: CurrentUser.user)
search_params = params.fetch(:search, {}).permit!
search_params = defaults.merge(search_params).with_indifferent_access
max_limit = (params[:format] == "sitemap") ? 10_000 : 1_000
search(search_params, current_user).paginate(page, limit: limit, max_limit: max_limit, count: count, search_count: count_pages)
end
end
end
concerning :PrivilegeMethods do
class_methods do
def visible(_user)
all
end
def visible_for_search(attribute, current_user)
policy(current_user).visible_for_search(all, attribute)
end
def policy(current_user)
Pundit.policy(current_user, self)
end
end
def policy(current_user)
Pundit.policy(current_user, self)
end
end
concerning :ApiMethods do
class_methods do
def available_includes
[]
end
def multiple_includes
reflections.select { |_, v| v.macro == :has_many }.keys.map(&:to_sym)
end
def associated_models(name)
if reflections[name].options[:polymorphic]
reflections[name].active_record.try(:model_types) || []
else
[reflections[name].class_name]
end
end
end
def available_includes
self.class.available_includes
end
# XXX deprecated, shouldn't expose this as an instance method.
def api_attributes(user: CurrentUser.user)
policy = Pundit.policy(user, self) || ApplicationPolicy.new(user, self)
policy.api_attributes
end
# XXX deprecated, shouldn't expose this as an instance method.
def html_data_attributes(user: CurrentUser.user)
policy = Pundit.policy(user, self) || ApplicationPolicy.new(user, self)
policy.html_data_attributes
end
def serializable_hash(options = {})
options ||= {}
if options[:only].is_a?(String)
options.delete(:methods)
options.delete(:include)
options.merge!(ParameterBuilder.serial_parameters(options[:only], self))
else
options[:methods] ||= []
attributes, methods = api_attributes.partition { |attr| has_attribute?(attr) }
methods += options[:methods]
options[:only] ||= attributes + methods
attributes &= options[:only]
methods &= options[:only]
options[:only] = attributes
options[:methods] = methods
options.delete(:methods) if options[:methods].empty?
end
hash = super(options)
hash.transform_keys { |key| key.delete("?") }
end
end
concerning :SearchMethods do
class_methods do
def model_restriction(table)
table.project(1)
end
def attribute_restriction(*)
all
end
end
end
concerning :ActiveRecordExtensions do
class_methods do
def set_timeout(n)
connection.execute("SET STATEMENT_TIMEOUT = #{n}") unless Rails.env.test?
yield
ensure
connection.execute("SET STATEMENT_TIMEOUT = #{CurrentUser.user.statement_timeout}") unless Rails.env.test?
end
def without_timeout
connection.execute("SET STATEMENT_TIMEOUT = 0") unless Rails.env.test?
yield
ensure
connection.execute("SET STATEMENT_TIMEOUT = #{CurrentUser.user.try(:statement_timeout) || 3_000}") unless Rails.env.test?
end
def with_timeout(n, default_value = nil, new_relic_params = {})
connection.execute("SET STATEMENT_TIMEOUT = #{n}") unless Rails.env.test?
yield
rescue ::ActiveRecord::StatementInvalid => e
DanbooruLogger.log(e, expected: true, **new_relic_params)
default_value
ensure
connection.execute("SET STATEMENT_TIMEOUT = #{CurrentUser.user.try(:statement_timeout) || 3_000}") unless Rails.env.test?
end
def update!(*args)
all.each { |record| record.update!(*args) }
end
def each_duplicate(*columns)
return enum_for(:each_duplicate, *columns) unless block_given?
group(columns).having("count(*) > 1").count.each do |values, count|
hash = columns.zip(Array.wrap(values)).to_h
yield count: count, **hash
end
end
def destroy_duplicates!(*columns, log: true)
each_duplicate(*columns) do |count:, **columns_with_values|
records = where(columns_with_values).order(:id)
dupes = records.drop(1)
if log
data = { keep: records.first.id, destroy: dupes.map(&:id), count: count, **columns_with_values }
DanbooruLogger.info("Destroying duplicate #{self.name} #{dupes.map(&:id).join(", ")}", data)
end
dupes.each(&:destroy!)
end
end
end
# Save the record, but convert RecordNotUnique exceptions thrown by the database into
# Rails validation errors. This way duplicate records only return one type of error.
# This assumes the table only has one uniqueness constraint in the database.
def save_if_unique(column)
save
rescue ActiveRecord::RecordNotUnique => e
self.errors.add(column, :taken)
false
end
end
concerning :UserMethods do
class_methods do
def belongs_to_updater(**options)
class_eval do
belongs_to :updater, class_name: "User", **options
before_validation do |rec|
rec.updater_id = CurrentUser.id
end
end
end
end
end
concerning :DtextMethods do
def dtext_shortlink(**_options)
"#{self.class.name.underscore.tr("_", " ")} ##{id}"
end
end
concerning :ConcurrencyMethods do
class_methods do
def parallel_each(batch_size: 1000, in_processes: 4, in_threads: nil, &block)
# XXX We may deadlock if a transaction is open; do a non-parallel each.
return find_each(&block) if connection.transaction_open?
# XXX Use threads in testing because processes can't see each other's
# database transactions.
if Rails.env.test?
in_processes = nil
in_threads = 2
end
current_user = CurrentUser.user
find_in_batches(batch_size: batch_size, error_on_ignore: true) do |batch|
Parallel.each(batch, in_processes: in_processes, in_threads: in_threads) do |record|
# XXX In threaded mode, the current user isn't inherited from the
# parent thread because the current user is a thread-local
# variable. Hence, we have to set it explicitly in the child thread.
CurrentUser.scoped(current_user) do
yield record
end
end
end
end
end
end
def revised?
updated_at > created_at
end
def warnings
@warnings ||= ActiveModel::Errors.new(self)
end
end