Don't allow GET requests to pass the request params in the body instead of in the URL. While Rails can handle GET params passed in the body, it goes against spec and it may cause problems if the response is a redirect and the client doesn't send the body params when following the redirect. This may be a breaking change for broken API clients who were sending GET params in the body instead of in the URL. This can happen when people use HTTP libraries incorrectly.
268 lines
10 KiB
Ruby
268 lines
10 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class ApplicationController < ActionController::Base
|
|
class PageRemovedError < StandardError; end
|
|
class RequestBodyNotAllowedError < StandardError; end
|
|
|
|
include Pundit::Authorization
|
|
helper_method :search_params, :permitted_attributes
|
|
|
|
self.responder = ApplicationResponder
|
|
|
|
skip_forgery_protection if: -> { SessionLoader.new(request).has_api_authentication? }
|
|
before_action :check_get_body
|
|
before_action :reset_current_user
|
|
before_action :set_current_user
|
|
before_action :normalize_search
|
|
before_action :check_default_rate_limit
|
|
before_action :ip_ban_check
|
|
before_action :set_variant
|
|
before_action :add_headers
|
|
before_action :cause_error
|
|
before_action :redirect_if_name_invalid?
|
|
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
|
|
|
|
# Define a rate limit for the given controller action.
|
|
#
|
|
# @example
|
|
# rate_limit :index, 1.0/1.minute, 50, if: -> { request.format.atom? }
|
|
def self.rate_limit(action, rate:, burst:, key: "#{controller_name}:#{action}", if: nil)
|
|
if_proc = binding.local_variable_get(:if)
|
|
|
|
before_action(only: action, if: if_proc) do
|
|
key = "#{controller_name}:#{action}"
|
|
rate_limiter = RateLimiter.build(action: key, rate: rate, burst: burst, user: CurrentUser.user, ip_addr: request.remote_ip)
|
|
headers["X-Rate-Limit"] = rate_limiter.to_json
|
|
rate_limiter.limit!
|
|
end
|
|
|
|
skip_before_action :check_default_rate_limit, only: action, if: if_proc
|
|
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 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
|
|
|
|
# Apply the default rate limit to all update actions (POST, PUT, or DELETE), unless the
|
|
# endpoint already declared a more specific rate limit using the `rate_limit` macro above.
|
|
def check_default_rate_limit
|
|
return if request.get? || request.head?
|
|
|
|
rate_limiter = RateLimiter.build(
|
|
action: "#{controller_name}:#{action_name}",
|
|
rate: CurrentUser.user.api_regen_multiplier,
|
|
burst: 200,
|
|
user: CurrentUser.user,
|
|
ip_addr: request.remote_ip,
|
|
)
|
|
|
|
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, message: exception.message)
|
|
when SessionLoader::AuthenticationFailure
|
|
render_error_page(401, exception, message: exception.message, template: "sessions/new")
|
|
when ActionController::InvalidAuthenticityToken, ActionController::UnpermittedParameters, ActionController::InvalidCrossOriginRequest, ActionController::Redirecting::UnsafeRedirectError
|
|
render_error_page(403, exception, message: exception.message)
|
|
when RequestBodyNotAllowedError
|
|
render_error_page(403, exception, message: "Request body not allowed for #{request.method} request")
|
|
when ActiveSupport::MessageVerifier::InvalidSignature, # raised by `find_signed!`
|
|
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, message: exception.message)
|
|
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 PostQuery::TagLimitError
|
|
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 PostQuery::Error
|
|
render_error_page(422, exception, message: exception.message)
|
|
when UpgradeCode::InvalidCodeError, UpgradeCode::RedeemedCodeError, UpgradeCode::AlreadyUpgradedError
|
|
render_error_page(422, exception, message: exception.message)
|
|
when RateLimiter::RateLimitError
|
|
render_error_page(429, exception, message: "Rate limit exceeded. You're doing that too fast")
|
|
when PageRemovedError
|
|
render_error_page(451, exception, template: "static/page_removed_error", message: "This page has been removed because of a takedown request")
|
|
when Rack::Timeout::RequestTimeoutException
|
|
render_error_page(500, exception, message: "Your request took too long to complete and was canceled.")
|
|
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.development? || Danbooru.config.debug_mode
|
|
render_error_page(500, exception)
|
|
end
|
|
end
|
|
|
|
def render_error_page(status, exception = nil, message: "", template: "static/error", format: request.format.symbol)
|
|
@exception = exception
|
|
@expected = status < 500
|
|
@message = message.to_s.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])
|
|
|
|
@api_response = { success: false, error: @exception.class.to_s, message: @message, backtrace: @backtrace }
|
|
|
|
# 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.safe_mode = false
|
|
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
|
|
|
|
def check_get_body
|
|
raise RequestBodyNotAllowedError if request.method.in?(%w[GET HEAD OPTIONS]) && request.body.size > 0
|
|
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 redirect_if_name_invalid?
|
|
if request.format.html? && !CurrentUser.user.is_anonymous? && CurrentUser.user.name_invalid?
|
|
flash[:notice] = "You must change your username to continue using #{Danbooru.config.app_name}"
|
|
redirect_to new_user_name_change_request_path
|
|
end
|
|
end
|
|
|
|
def ip_ban_check
|
|
raise User::PrivilegeError if !request.get? && IpBan.hit!(:full, request.remote_ip)
|
|
end
|
|
|
|
def pundit_user
|
|
CurrentUser.user
|
|
end
|
|
|
|
def pundit_params_for(record)
|
|
params.fetch(Pundit::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.zone.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
|