461 lines
18 KiB
Ruby
461 lines
18 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# Copyright (C) 2013 - present Instructure, Inc.
|
|
#
|
|
# This file is part of Canvas.
|
|
#
|
|
# Canvas is free software: you can redistribute it and/or modify it under
|
|
# the terms of the GNU Affero General Public License as published by the Free
|
|
# Software Foundation, version 3 of the License.
|
|
#
|
|
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License along
|
|
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
require_relative "boot"
|
|
|
|
require "active_record/railtie"
|
|
require "action_controller/railtie"
|
|
require "action_mailer/railtie"
|
|
# require "sprockets/railtie" # Do not enable the Rails Asset Pipeline
|
|
require "rails/test_unit/railtie"
|
|
|
|
Bundler.require(*Rails.groups)
|
|
|
|
debug_launch = lambda do
|
|
if ENV["RUBY_DEBUG_OPEN"]
|
|
require "debug/session"
|
|
next unless defined?(DEBUGGER__)
|
|
|
|
DEBUGGER__.open(nonstop: ENV["RUBY_DEBUG_NONSTOP"])
|
|
elsif ENV["RUBY_DEBUG_START"]
|
|
require "debug/start"
|
|
end
|
|
end
|
|
|
|
Spring.after_fork(&debug_launch) if defined?(Spring)
|
|
debug_launch.call if !defined?(Passenger) && Rails.env.development?
|
|
|
|
module CanvasRails
|
|
class Application < Rails::Application
|
|
config.autoloader = :zeitwerk
|
|
|
|
config.add_autoload_paths_to_load_path = false
|
|
|
|
config.encoding = "utf-8"
|
|
require "logging_filter"
|
|
config.filter_parameters.concat LoggingFilter.filtered_parameters
|
|
config.action_dispatch.rescue_responses["AuthenticationMethods::AccessTokenError"] = 401
|
|
config.action_dispatch.rescue_responses["AuthenticationMethods::AccessTokenScopeError"] = 401
|
|
config.action_dispatch.rescue_responses["AuthenticationMethods::LoggedOutError"] = 401
|
|
config.action_dispatch.rescue_responses["CanvasHttp::CircuitBreakerError"] = 502
|
|
config.action_dispatch.default_headers.delete("X-Frame-Options")
|
|
config.action_dispatch.default_headers["Referrer-Policy"] = "no-referrer-when-downgrade"
|
|
config.action_controller.forgery_protection_origin_check = true
|
|
ActiveSupport.to_time_preserves_timezone = true
|
|
# Ensure switchman gets the new version before the main initialize_cache initializer runs
|
|
config.active_support.cache_format_version = ActiveSupport.cache_format_version = 7.0
|
|
|
|
config.app_generators do |c|
|
|
c.test_framework :rspec
|
|
c.integration_tool :rspec
|
|
c.performance_tool :rspec
|
|
end
|
|
|
|
# Settings in config/environments/* take precedence over those specified here.
|
|
# Application configuration should go into files in config/initializers
|
|
# -- all .rb files in that directory are automatically loaded.
|
|
# See Rails::Configuration for more options.
|
|
|
|
# Make Time.zone default to the specified zone, and make Active Record store time values
|
|
# in the database in UTC, and return them converted to the specified local zone.
|
|
# Run "rake -D time" for a list of tasks for finding time zone names. Comment line to use default local time.
|
|
config.time_zone = "UTC"
|
|
|
|
log_config = Rails.root.join("config/logging.yml").file? && Rails.application.config_for(:logging).with_indifferent_access
|
|
log_config = { "logger" => "rails", "log_level" => "debug" }.merge(log_config || {})
|
|
|
|
config.log_level = log_config["log_level"]
|
|
log_level = Logger.const_get(config.log_level.to_s.upcase)
|
|
|
|
case log_config["logger"]
|
|
when "syslog"
|
|
require "syslog/logger"
|
|
log_config["app_ident"] ||= "canvas-lms"
|
|
log_config["daemon_ident"] ||= "canvas-lms-daemon"
|
|
facilities = 0
|
|
(log_config["facilities"] || []).each do |facility|
|
|
facilities |= Syslog.const_get :"LOG_#{facility.to_s.upcase}"
|
|
end
|
|
ident = (ENV["RUNNING_AS_DAEMON"] == "true") ? log_config["daemon_ident"] : log_config["app_ident"]
|
|
|
|
config.logger = Syslog::Logger.new(ident, facilities)
|
|
|
|
syslog_options = (log_config["include_pid"] == true) ? Syslog::LOG_PID : 0
|
|
if (Syslog.instance.options & Syslog::LOG_PID) != syslog_options
|
|
config.logger.syslog = Syslog.reopen(Syslog.instance.ident,
|
|
(Syslog.instance.options & ~Syslog::LOG_PID) | syslog_options,
|
|
Syslog.instance.facility)
|
|
end
|
|
else
|
|
require "canvas_logger"
|
|
log_path = config.paths["log"].first
|
|
|
|
if ENV["RUNNING_AS_DAEMON"] == "true"
|
|
log_path = Rails.root.join("log/delayed_job.log")
|
|
end
|
|
|
|
FileUtils.mkdir_p(File.dirname(log_path))
|
|
config.logger = CanvasLogger.new(log_path, log_level)
|
|
end
|
|
config.logger.level = log_level
|
|
unless log_config["log_context"] == false
|
|
class ContextFormatter < Logger::Formatter
|
|
def initialize(parent_formatter)
|
|
super()
|
|
|
|
@parent_formatter = parent_formatter
|
|
end
|
|
|
|
def call(severity, time, progname, msg)
|
|
msg = @parent_formatter.call(severity, time, progname, msg)
|
|
context = Thread.current[:context] || {}
|
|
"[#{context[:session_id] || "-"} #{context[:request_id] || "-"}] #{msg}"
|
|
end
|
|
end
|
|
|
|
config.logger.formatter = ContextFormatter.new(config.logger.formatter)
|
|
end
|
|
|
|
# Activate observers that should always be running
|
|
config.active_record.observers = %i[cacher stream_item_cache live_events_observer]
|
|
|
|
config.active_support.encode_big_decimal_as_string = false
|
|
config.active_support.remove_deprecated_time_with_zone_name = true
|
|
|
|
config.paths["lib"].eager_load!
|
|
config.paths.add("app/middleware", eager_load: true, autoload_once: true)
|
|
# The main autoloader should ignore it so the `once` autoloader can happily load it
|
|
Rails.autoloaders.main.ignore("#{__dir__}/../lib/base")
|
|
config.paths.add("lib/base", eager_load: true, autoload_once: true)
|
|
$LOAD_PATH << "#{__dir__}/../lib/base"
|
|
|
|
# This needs to be set for things in the `once` autoloader really early
|
|
Rails.autoloaders.each do |autoloader|
|
|
autoloader.inflector.inflect(
|
|
"csv_with_i18n" => "CSVWithI18n"
|
|
)
|
|
end
|
|
|
|
# prevent directory->module inference in these directories from wreaking
|
|
# havoc on the app (e.g. stylesheets/base -> ::Base)
|
|
config.eager_load_paths -= [Rails.root.join("app/coffeescripts"),
|
|
Rails.root.join("app/stylesheets"),
|
|
Rails.root.join("ui")]
|
|
|
|
config.middleware.use Rack::Chunked
|
|
config.middleware.use Rack::Deflater, if: lambda { |*|
|
|
::DynamicSettings.find(tree: :private)["enable_rack_deflation", failsafe: true]
|
|
}
|
|
config.middleware.use Rack::Brotli, if: lambda { |*|
|
|
::DynamicSettings.find(tree: :private)["enable_rack_brotli", failsafe: true]
|
|
}
|
|
|
|
config.i18n.load_path << Rails.root.join("config/locales/locales.yml")
|
|
config.i18n.load_path << Rails.root.join("config/locales/community.csv")
|
|
|
|
config.to_prepare do
|
|
Canvas::Plugins::DefaultPlugins.apply_all
|
|
ActiveSupport::JSON::Encoding.escape_html_entities_in_json = true
|
|
end
|
|
|
|
module PostgreSQLEarlyExtensions
|
|
module ConnectionHandling
|
|
def postgresql_connection(config)
|
|
conn_params = config.symbolize_keys
|
|
|
|
hosts = Array(conn_params[:host]).presence || [nil]
|
|
hosts.each_with_index do |host, index|
|
|
conn_params[:host] = host
|
|
|
|
begin
|
|
return super(conn_params)
|
|
rescue ::ActiveRecord::ConnectionNotEstablished => e
|
|
# If exception occurs using parameters from a predefined pg service, retry without
|
|
if conn_params.key?(:service)
|
|
CanvasErrors.capture(e, { tags: { pg_service: conn_params[:service] } }, :warn)
|
|
Rails.logger.warn("Error connecting to database using pg service `#{conn_params[:service]}`; retrying without... (error: #{e.message})")
|
|
conn_params.delete(:service)
|
|
retry
|
|
else
|
|
raise
|
|
end
|
|
end
|
|
# we _shouldn't_ be catching a NoDatabaseError, but that's what Rails raises
|
|
# for an error where the database name is in the message (i.e. a hostname lookup failure)
|
|
rescue ::ActiveRecord::NoDatabaseError, ::ActiveRecord::ConnectionNotEstablished
|
|
raise if index == hosts.length - 1
|
|
# else try next host
|
|
end
|
|
end
|
|
end
|
|
|
|
if Rails.version < "7.1"
|
|
def initialize(connection, logger, connection_parameters, config)
|
|
unless config.key?(:prepared_statements)
|
|
config = config.dup
|
|
config[:prepared_statements] = false
|
|
end
|
|
super(connection, logger, connection_parameters, config)
|
|
end
|
|
else
|
|
def initialize(config)
|
|
unless config.key?(:prepared_statements)
|
|
config = config.dup
|
|
config[:prepared_statements] = false
|
|
end
|
|
super(config)
|
|
end
|
|
end
|
|
|
|
def connect
|
|
hosts = Array(@connection_parameters[:host]).presence || [nil]
|
|
hosts.each_with_index do |host, index|
|
|
connection_parameters = @connection_parameters.dup
|
|
connection_parameters[:host] = host
|
|
|
|
begin
|
|
if Rails.version < "7.1"
|
|
@connection = PG::Connection.connect(connection_parameters)
|
|
else
|
|
@raw_connection = PG::Connection.connect(connection_parameters)
|
|
end
|
|
rescue ::PG::Error => e
|
|
# If exception occurs using parameters from a predefined pg service, retry without
|
|
if connection_parameters.key?(:service)
|
|
CanvasErrors.capture(e, { tags: { pg_service: connection_parameters[:service] } }, :warn)
|
|
Rails.logger.warn("Error connecting to database using pg service `#{connection_parameters[:service]}`; retrying without... (error: #{e.message})")
|
|
connection_parameters.delete(:service)
|
|
retry
|
|
else
|
|
raise
|
|
end
|
|
end
|
|
|
|
configure_connection
|
|
|
|
raise "Canvas requires PostgreSQL 12 or newer" unless postgresql_version >= 12_00_00 # rubocop:disable Style/NumericLiterals
|
|
|
|
break
|
|
rescue ::PG::Error => e
|
|
if e.message.include?("does not exist")
|
|
raise ActiveRecord::NoDatabaseError, e.message
|
|
elsif index == hosts.length - 1
|
|
raise
|
|
end
|
|
# else try next host
|
|
end
|
|
end
|
|
end
|
|
|
|
module TypeMapInitializerExtensions
|
|
def query_conditions_for_initial_load
|
|
known_type_names = @store.keys.map { |n| "'#{n}'" } + @store.keys.map { |n| "'_#{n}'" }
|
|
<<~SQL.squish % [known_type_names.join(", "),]
|
|
WHERE
|
|
t.typname IN (%s)
|
|
SQL
|
|
end
|
|
end
|
|
|
|
Autoextend.hook(:"ActiveRecord::Base",
|
|
PostgreSQLEarlyExtensions::ConnectionHandling,
|
|
singleton: true)
|
|
Autoextend.hook(:"ActiveRecord::ConnectionAdapters::PostgreSQLAdapter",
|
|
PostgreSQLEarlyExtensions,
|
|
method: :prepend)
|
|
Autoextend.hook(:"ActiveRecord::ConnectionAdapters::PostgreSQL::OID::TypeMapInitializer",
|
|
TypeMapInitializerExtensions,
|
|
method: :prepend)
|
|
|
|
module RailsCacheShim
|
|
def delete(key, options = nil)
|
|
if options&.[](:unprefixed_key)
|
|
super
|
|
else
|
|
# Any is eager, so we must map first or we won't run on all keys
|
|
SUPPORTED_RAILS_VERSIONS.map do |version|
|
|
super(key, (options || {}).merge(explicit_version: version.delete(".")))
|
|
end.any?
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def namespace_key(key, options)
|
|
# Purge all rails versions at once if deleting based on a pattern
|
|
if caller_locations(1, 1).first.base_label == "delete_matched"
|
|
return "rails??:#{super}"
|
|
end
|
|
|
|
if options&.[](:unprefixed_key)
|
|
super
|
|
elsif options&.[](:explicit_version)
|
|
"rails#{options[:explicit_version]}:#{super}"
|
|
else
|
|
"rails#{Rails::VERSION::MAJOR}#{Rails::VERSION::MINOR}:#{super}"
|
|
end
|
|
end
|
|
end
|
|
|
|
Autoextend.hook(:"ActiveSupport::Cache::Store",
|
|
RailsCacheShim,
|
|
method: :prepend)
|
|
|
|
module PatchThorWarning
|
|
# active_model_serializers should be passing `type: :boolean` here:
|
|
# https://github.com/rails-api/active_model_serializers/blob/v0.9.0.alpha1/lib/active_model/serializer/generators/serializer/scaffold_controller_generator.rb#L10
|
|
# but we don't really care about the warning, it only affects using the rails
|
|
# generator for a resource
|
|
#
|
|
# Easiest way to avoid the warning for now is to patch thor
|
|
def validate_default_type!
|
|
return if switch_name == "--serializer"
|
|
|
|
super
|
|
end
|
|
end
|
|
|
|
Autoextend.hook(:"Thor::Option", PatchThorWarning, method: :prepend)
|
|
|
|
# tell Rails to use the native XML parser instead of REXML
|
|
ActiveSupport::XmlMini.backend = "Nokogiri"
|
|
|
|
class NotImplemented < StandardError; end
|
|
|
|
if defined?(PhusionPassenger)
|
|
PhusionPassenger.on_event(:after_installing_signal_handlers) do
|
|
Canvas::Reloader.trap_signal
|
|
end
|
|
else
|
|
config.to_prepare do
|
|
Canvas::Reloader.trap_signal
|
|
end
|
|
end
|
|
|
|
# Ensure that the automatic redis reconnection on fork works
|
|
# This is the default in redis-rb, but for some reason rails overrides it
|
|
# See e.g. https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22704
|
|
ActiveSupport::Cache::RedisCacheStore::DEFAULT_REDIS_OPTIONS[:reconnect_attempts] = 1
|
|
|
|
# don't wrap fields with errors with a <div class="fieldWithErrors" />,
|
|
# since that could leak information (e.g. valid vs invalid username on
|
|
# login page)
|
|
config.action_view.field_error_proc = proc { |html_tag, _instance| html_tag }
|
|
|
|
class ExceptionsApp
|
|
def call(env)
|
|
req = ActionDispatch::Request.new(env)
|
|
res = ApplicationController.make_response!(req)
|
|
ApplicationController.dispatch("rescue_action_dispatch_exception", req, res)
|
|
end
|
|
end
|
|
|
|
config.exceptions_app = ExceptionsApp.new
|
|
|
|
config.before_initialize do
|
|
config.action_controller.asset_host = lambda do |source, *_|
|
|
::Canvas::Cdn.asset_host_for(source)
|
|
end
|
|
end
|
|
|
|
if config.action_dispatch.rack_cache != false
|
|
config.action_dispatch.rack_cache[:ignore_headers] =
|
|
%w[Set-Cookie X-Request-Context-Id X-Canvas-User-Id X-Canvas-Meta]
|
|
end
|
|
|
|
def validate_secret_key_base(_)
|
|
# no validation; we don't use Rails' CookieStore session middleware, so we
|
|
# don't care about secret_key_base
|
|
end
|
|
|
|
class DummyKeyGenerator
|
|
def self.generate_key(*); end
|
|
end
|
|
|
|
def key_generator(...)
|
|
DummyKeyGenerator
|
|
end
|
|
|
|
# # This also depends on secret_key_base and is not a feature we use or currently intend to support
|
|
unless Rails.version < "7.1"
|
|
initializer "canvas.ignore_generated_token_verifier", before: "active_record.generated_token_verifier" do
|
|
config.after_initialize do
|
|
ActiveSupport.on_load(:active_record) do
|
|
self.generated_token_verifier = "UNUSED"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
initializer "canvas.init_dynamic_settings", before: "canvas.extend_shard" do
|
|
settings = ConfigFile.load("consul")
|
|
if settings.present?
|
|
# this is not just for speed in non-consul installations^
|
|
# We also do things like building javascript assets with the base
|
|
# container that only has as many ruby assets as strictly necessary,
|
|
# and these resources actually aren't even on disk in those cases.
|
|
# do not remove this conditional until the asset build no longer
|
|
# needs the rails app for anything.
|
|
|
|
# Do it early with the wrong cache for things super early in boot
|
|
reloader = DynamicSettingsInitializer.bootstrap!
|
|
# Do it at the end when the autoloader is set up correctly
|
|
config.to_prepare do
|
|
reloader.call
|
|
end
|
|
end
|
|
end
|
|
|
|
initializer "canvas.extend_shard", before: "active_record.initialize_database" do
|
|
# have to do this before the default shard loads
|
|
Switchman::Shard.serialize :settings, type: Hash
|
|
Switchman.cache = -> { MultiCache.cache }
|
|
end
|
|
|
|
# Newer rails has this in rails proper
|
|
attr_writer :credentials
|
|
|
|
initializer "canvas.init_credentials", before: "active_record.initialize_database" do
|
|
self.credentials = Canvas::Credentials.new(credentials)
|
|
# Ensure we load credentials at initailization time to avoid overloading vault
|
|
credentials.config
|
|
end
|
|
|
|
# we don't know what middleware to make SessionsTimeout follow until after
|
|
# we've loaded config/initializers/session_store.rb
|
|
initializer("extend_middleware_stack", after: :load_config_initializers) do |app|
|
|
app.config.middleware.insert_before(config.session_store, LoadAccount)
|
|
app.config.middleware.swap(ActionDispatch::RequestId, RequestContext::Generator)
|
|
app.config.middleware.insert_after(config.session_store, RequestContext::Session)
|
|
app.config.middleware.insert_before(Rack::Head, RequestThrottle)
|
|
app.config.middleware.insert_before(Rack::MethodOverride, PreventNonMultipartParse)
|
|
app.config.middleware.insert_before(Sentry::Rails::CaptureExceptions, SentryTraceScrubber)
|
|
end
|
|
|
|
initializer("set_allowed_request_id_setters", after: :finisher_hook) do |app|
|
|
# apparently there is no initialization hook that comes late enough for
|
|
# routes to already be loaded, so we have to load them explicitly
|
|
app.reload_routes!
|
|
RequestContext::Generator.allow_unsigned_request_context_for(
|
|
app.routes.url_helpers.api_graphql_subgraph_path
|
|
)
|
|
end
|
|
end
|
|
end
|