Rework the rate limit implementation to make it more flexible: * Allow setting different rate limits for different actions. Before we had a single rate limit for all write actions. Now different controller endpoints can have different limits. * Allow actions to be rate limited by user ID, by IP address, or both. Before actions were only limited by user ID, which meant non-logged-in actions like creating new accounts or attempting to login couldn't be rate limited. Also, because actions were limited by user ID only, you could use multiple accounts with the same IP to get around limits. Other changes: * Remove the API Limit field from user profile pages. * Remove the `remaining_api_limit` field from the `/profile.json` endpoint. * Rename the `X-Api-Limit` header to `X-Rate-Limit` and change it from a number to a JSON object containing all the rate limit info (including the refill rate, the burst factor, the cost of the call, and the current limits). * Fix a potential race condition where, if you flooded requests fast enough, you could exceed the rate limit. This was because we checked and updated the rate limit in two separate steps, which was racy; simultaneous requests could pass the check before the update happened. The new code uses some tricky SQL to check and update multiple limits in a single statement.
221 lines
7.6 KiB
Ruby
221 lines
7.6 KiB
Ruby
class ApplicationController < ActionController::Base
|
|
include Pundit
|
|
helper_method :search_params
|
|
|
|
self.responder = ApplicationResponder
|
|
|
|
skip_forgery_protection if: -> { SessionLoader.new(request).has_api_authentication? }
|
|
before_action :reset_current_user
|
|
before_action :set_current_user
|
|
before_action :normalize_search
|
|
before_action :api_check
|
|
before_action :ip_ban_check
|
|
before_action :set_variant
|
|
before_action :add_headers
|
|
before_action :cause_error
|
|
after_action :skip_session_if_publicly_cached
|
|
after_action :reset_current_user
|
|
layout "default"
|
|
|
|
rescue_from Exception, :with => :rescue_exception
|
|
|
|
def self.rescue_with(*klasses, status: 500)
|
|
rescue_from(*klasses) do |exception|
|
|
render_error_page(status, exception)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def respond_with(subject, *args, model: model_name, **options, &block)
|
|
if params[:action] == "index" && is_redirect?(subject)
|
|
redirect_to_show(subject)
|
|
return
|
|
end
|
|
|
|
if subject.respond_to?(:includes) && (request.format.json? || request.format.xml?)
|
|
associations = ParameterBuilder.includes_parameters(params[:only], model)
|
|
subject = subject.includes(associations)
|
|
end
|
|
|
|
@current_item = subject
|
|
super
|
|
end
|
|
|
|
def set_version_comparison(default_type = "previous")
|
|
params[:type] = %w[previous subsequent current].include?(params[:type]) ? params[:type] : default_type
|
|
end
|
|
|
|
def model_name
|
|
controller_name.classify
|
|
end
|
|
|
|
def redirect_to_show(items)
|
|
redirect_to send("#{controller_path.singularize}_path", items.first, format: request.format.symbol)
|
|
end
|
|
|
|
def is_redirect?(items)
|
|
action_methods.include?("show") && params[:redirect].to_s.truthy? && items.one? && item_matches_params(items.first)
|
|
end
|
|
|
|
def item_matches_params(*)
|
|
true
|
|
end
|
|
|
|
protected
|
|
|
|
def add_headers
|
|
response.headers["Access-Control-Allow-Origin"] = "*"
|
|
response.headers["X-Git-Hash"] = Rails.application.config.x.git_hash
|
|
end
|
|
|
|
def api_check
|
|
return if CurrentUser.is_anonymous? || request.get? || request.head?
|
|
|
|
rate_limiter = RateLimiter.new(
|
|
"write",
|
|
[CurrentUser.user.cache_key],
|
|
cost: 1,
|
|
rate: CurrentUser.user.api_regen_multiplier,
|
|
burst: CurrentUser.user.api_burst_limit
|
|
)
|
|
|
|
headers["X-Rate-Limit"] = rate_limiter.to_json
|
|
rate_limiter.limit!
|
|
end
|
|
|
|
def rescue_exception(exception)
|
|
case exception
|
|
when ActionView::Template::Error
|
|
rescue_exception(exception.cause)
|
|
when ActiveRecord::QueryCanceled
|
|
render_error_page(500, exception, template: "static/search_timeout", message: "The database timed out running your query.")
|
|
when ActionController::BadRequest
|
|
render_error_page(400, exception)
|
|
when SessionLoader::AuthenticationFailure
|
|
render_error_page(401, exception, template: "sessions/new")
|
|
when ActionController::InvalidAuthenticityToken, ActionController::UnpermittedParameters, ActionController::InvalidCrossOriginRequest
|
|
render_error_page(403, exception)
|
|
when ActiveSupport::MessageVerifier::InvalidSignature # raised by `find_signed!`
|
|
render_error_page(403, exception, template: "static/access_denied", message: "Access denied")
|
|
when User::PrivilegeError, Pundit::NotAuthorizedError
|
|
render_error_page(403, exception, template: "static/access_denied", message: "Access denied")
|
|
when ActiveRecord::RecordNotFound
|
|
render_error_page(404, exception, message: "That record was not found.")
|
|
when ActionController::RoutingError
|
|
render_error_page(405, exception)
|
|
when ActionController::UnknownFormat, ActionView::MissingTemplate
|
|
render_error_page(406, exception, message: "#{request.format} is not a supported format for this page")
|
|
when PaginationExtension::PaginationError
|
|
render_error_page(410, exception, template: "static/pagination_error", message: "You cannot go beyond page #{CurrentUser.user.page_limit}.")
|
|
when Post::SearchError
|
|
render_error_page(422, exception, template: "static/tag_limit_error", message: "You cannot search for more than #{CurrentUser.tag_query_limit} tags at a time.")
|
|
when RateLimiter::RateLimitError
|
|
render_error_page(429, exception)
|
|
when NotImplementedError
|
|
render_error_page(501, exception, message: "This feature isn't available: #{exception.message}")
|
|
when PG::ConnectionBad
|
|
render_error_page(503, exception, message: "The database is unavailable. Try again later.")
|
|
else
|
|
raise exception if !Rails.env.production? || Danbooru.config.debug_mode
|
|
render_error_page(500, exception)
|
|
end
|
|
end
|
|
|
|
def render_error_page(status, exception = nil, message: exception.message, template: "static/error", format: request.format.symbol)
|
|
@exception = exception
|
|
@expected = status < 500
|
|
@message = message.encode("utf-8", invalid: :replace, undef: :replace)
|
|
@backtrace = Rails.backtrace_cleaner.clean(@exception.backtrace) if @exception
|
|
format = :html unless format.in?(%i[html json xml js atom])
|
|
|
|
# if InvalidAuthenticityToken was raised, CurrentUser isn't set so we have to use the blank layout.
|
|
layout = CurrentUser.user.present? ? "default" : "blank"
|
|
|
|
DanbooruLogger.log(@exception, expected: @expected) if @exception
|
|
render template, layout: layout, status: status, formats: format
|
|
rescue ActionView::MissingTemplate
|
|
render "static/error", layout: layout, status: status, formats: format
|
|
end
|
|
|
|
def set_current_user
|
|
SessionLoader.new(request).load
|
|
end
|
|
|
|
def reset_current_user
|
|
CurrentUser.user = nil
|
|
CurrentUser.ip_addr = nil
|
|
CurrentUser.safe_mode = false
|
|
CurrentUser.root_url = root_url.chomp("/")
|
|
end
|
|
|
|
# Skip setting the session cookie if the response is being publicly cached to
|
|
# prevent the user's session cookie from being leaked to other users.
|
|
def skip_session_if_publicly_cached
|
|
if response.cache_control[:public] == true
|
|
request.session_options[:skip] = true
|
|
end
|
|
end
|
|
|
|
def set_variant
|
|
request.variant = params[:variant].try(:to_sym)
|
|
end
|
|
|
|
# allow api clients to force errors for testing purposes.
|
|
def cause_error
|
|
return unless params[:cause_error].present?
|
|
|
|
status = params[:cause_error].to_i
|
|
raise ArgumentError, "invalid status code" unless status.in?(400..599)
|
|
|
|
error = StandardError.new(params[:message])
|
|
error.set_backtrace(caller)
|
|
|
|
render_error_page(status, error)
|
|
end
|
|
|
|
def ip_ban_check
|
|
raise User::PrivilegeError if !request.get? && IpBan.hit!(:full, CurrentUser.ip_addr)
|
|
end
|
|
|
|
def pundit_user
|
|
CurrentUser.user
|
|
end
|
|
|
|
def pundit_params_for(record)
|
|
params.fetch(PolicyFinder.new(record).param_key, {})
|
|
end
|
|
|
|
def requires_reauthentication
|
|
return if CurrentUser.user.is_anonymous?
|
|
|
|
last_authenticated_at = session[:last_authenticated_at]
|
|
if last_authenticated_at.blank? || Time.parse(last_authenticated_at) < 60.minutes.ago
|
|
redirect_to confirm_password_session_path(url: request.fullpath)
|
|
end
|
|
end
|
|
|
|
# Remove blank `search` params from the url.
|
|
#
|
|
# /tags?search[name]=touhou&search[category]=&search[order]=
|
|
# => /tags?search[name]=touhou
|
|
def normalize_search
|
|
return unless request.get? || request.head?
|
|
params[:search] ||= ActionController::Parameters.new
|
|
|
|
deep_reject_blank = lambda do |hash|
|
|
hash.reject { |k, v| v.blank? || (v.is_a?(Hash) && deep_reject_blank.call(v).blank?) }
|
|
end
|
|
nonblank_search_params = deep_reject_blank.call(params[:search])
|
|
|
|
if nonblank_search_params != params[:search]
|
|
params[:search] = nonblank_search_params
|
|
redirect_to url_for(params: params.except(:controller, :action, :index).permit!)
|
|
end
|
|
end
|
|
|
|
def search_params
|
|
params.fetch(:search, {}).permit!
|
|
end
|
|
end
|