diff --git a/lib/rails_multisite.rb b/lib/rails_multisite.rb index 9244297..a9aea39 100644 --- a/lib/rails_multisite.rb +++ b/lib/rails_multisite.rb @@ -8,3 +8,4 @@ require 'rails_multisite/connection_management' require 'rails_multisite/middleware' require 'rails_multisite/cookie_salt' +require 'rails_multisite/action_view_helper' diff --git a/lib/rails_multisite/action_view_helper.rb b/lib/rails_multisite/action_view_helper.rb new file mode 100644 index 0000000..c26317a --- /dev/null +++ b/lib/rails_multisite/action_view_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module RailsMultisite + module ActionViewHelper + def config=(value) + value.define_singleton_method(:relative_url_root) do + RailsMultisite::ConnectionManagement.current_path_prefix + end + + super(value) + end + end +end diff --git a/lib/rails_multisite/connection_management.rb b/lib/rails_multisite/connection_management.rb index fd107cf..efe1f23 100644 --- a/lib/rails_multisite/connection_management.rb +++ b/lib/rails_multisite/connection_management.rb @@ -12,12 +12,13 @@ class ConnectionManagement attr_reader :config_filename, :db_spec_cache class << self - attr_accessor :asset_hostnames + attr_accessor :asset_hostnames, :default_path_prefix delegate :all_dbs, :config_filename, :connection_spec, :current_db, + :current_path_prefix, :default_connection_handler=, :each_connection, :establish_connection, @@ -105,6 +106,7 @@ def load_config! raise ArgumentError.new("Please do not name any db default!") end v[:db_key] = k + v[:path_prefix] = normalize_path_prefix(v["path_prefix"]) v[:prepared_statements] = false if no_prepared_statements end @@ -117,20 +119,42 @@ def load_config! end end - # Build a hash of hostname => spec + # Build a hash of hostname => spec (hostname-only sites only; path-prefix + # sites are excluded so unknown prefixes produce 404 rather than a wrong match) new_host_spec_cache = {} configs.each do |k, v| next unless v["host_names"] + next if v[:path_prefix] v["host_names"].each do |host| new_host_spec_cache[host] = new_db_spec_cache[k] end end - # Add the default hostnames as well - @default_spec.config[:host_names].each do |host| - new_host_spec_cache[host] = @default_spec + # Add the default hostnames (unless default has a path prefix) + unless normalize_path_prefix(self.class.default_path_prefix) + @default_spec.config[:host_names].each do |host| + new_host_spec_cache[host] = @default_spec + end + end + + # Build a hash of "hostname/path_prefix" => spec, sorted longest-key-first + new_path_spec_cache = {} + configs.each do |k, v| + next unless v["host_names"] && v[:path_prefix] + v["host_names"].each do |host| + new_path_spec_cache["#{host}#{v[:path_prefix]}"] = new_db_spec_cache[k] + end end + @default_path_prefix = normalize_path_prefix(self.class.default_path_prefix) + if @default_path_prefix + @default_spec.config[:host_names].each do |host| + new_path_spec_cache["#{host}#{@default_path_prefix}"] = @default_spec + end + end + + @path_spec_cache = new_path_spec_cache.sort_by { |k, _| -k.length }.to_h + removed_dbs = db_spec_cache.keys - new_db_spec_cache.keys removed_specs = db_spec_cache.values_at(*removed_dbs) @@ -195,6 +219,8 @@ def with_hostname(hostname) rval end + # TODO: add with_path_prefix or a composite key so that applications quickly switch + def with_connection(db = DEFAULT) old = current_db connected = ActiveRecord::Base.connection_pool.connected? @@ -305,7 +331,32 @@ def host(env) end def connection_spec(opts) - opts[:host] ? @host_spec_cache[opts[:host]] : db_spec_cache[opts[:db]] + # if a key is provided (matching hostname/path_prefix or hostname), use it to find the spec directly + if opts[:key] + found_spec = @path_spec_cache[opts[:key]] || @host_spec_cache[opts[:key]] + return found_spec if found_spec + end + + if opts[:host] + path_info = opts[:path] + if path_info && path_info.length > 1 + request_path = "#{opts[:host]}#{path_info}" + @path_spec_cache.each do |composite_key, spec| + if composite_key == request_path || request_path.start_with?("#{composite_key}/") + return spec + end + end + end + + @host_spec_cache[opts[:host]] + else + db_spec_cache[opts[:db]] + end + end + + def current_path_prefix + return @default_path_prefix if current_db == DEFAULT + ConnectionSpecification.current.config[:path_prefix] end def clear_settings! @@ -329,5 +380,12 @@ def default_connection_handler=(connection_handler) def handler_key(spec) self.class.handler_key(spec) end + + def normalize_path_prefix(raw) + return nil if raw.nil? || raw.to_s.strip.empty? + prefix = raw.to_s.strip + prefix = "/#{prefix}" unless prefix.start_with?("/") + prefix.chomp("/") + end end end diff --git a/lib/rails_multisite/connection_management/null_instance.rb b/lib/rails_multisite/connection_management/null_instance.rb index 5911734..d93f422 100644 --- a/lib/rails_multisite/connection_management/null_instance.rb +++ b/lib/rails_multisite/connection_management/null_instance.rb @@ -32,6 +32,14 @@ def current_db DEFAULT end + def current_path_prefix + nil + end + + def default_path_prefix + nil + end + def each_connection(_opts = nil, &blk) with_connection(&blk) end diff --git a/lib/rails_multisite/middleware.rb b/lib/rails_multisite/middleware.rb index f3cae7d..914642b 100644 --- a/lib/rails_multisite/middleware.rb +++ b/lib/rails_multisite/middleware.rb @@ -10,8 +10,10 @@ def call(env) host = ConnectionManagement.host(env) db = nil begin + key = env["HTTP_MULTISITE_ROUTE"] if env["HTTP_MULTISITE_ROUTE"].present? + spec = ConnectionManagement.connection_spec(host: host, path: env["PATH_INFO"], key: key) - unless ConnectionManagement.connection_spec(host: host) + unless spec db = @db_lookup && @db_lookup.call(env) if db host = nil @@ -20,6 +22,20 @@ def call(env) end end + matched_prefix = spec&.config&.fetch(:path_prefix, nil) || + (spec && !spec.config[:db_key] ? ConnectionManagement.default_path_prefix : nil) + + if matched_prefix + path_info = env["PATH_INFO"].to_s + env["SCRIPT_NAME"] = env["SCRIPT_NAME"].to_s + matched_prefix + env["PATH_INFO"] = path_info[matched_prefix.length..] + env["PATH_INFO"] = "/" if env["PATH_INFO"].nil? || env["PATH_INFO"].empty? + if spec.config[:db_key] + db = spec.config[:db_key] + host = nil + end + end + ActiveRecord::Base.connection_handler.clear_active_connections! ConnectionManagement.establish_connection(host: host, db: db) CookieSalt.update_cookie_salts(env: env, host: host) diff --git a/lib/rails_multisite/railtie.rb b/lib/rails_multisite/railtie.rb index 96bfcb0..cfdeda7 100644 --- a/lib/rails_multisite/railtie.rb +++ b/lib/rails_multisite/railtie.rb @@ -15,23 +15,38 @@ class Railtie < Rails::Railtie config_file ||= ConnectionManagement.default_config_filename + default_path_prefix = + app.config.respond_to?(:multisite_default_path_prefix) && + app.config.multisite_default_path_prefix.presence + if File.exist?(config_file) + ConnectionManagement.default_path_prefix = default_path_prefix ConnectionManagement.config_filename = config_file app.config.multisite = true - Rails.logger.formatter = RailsMultisite::Formatter.new + Rails.logger.formatter = RailsMultisite::Formatter.new if Rails.logger - if !skip_middleware?(app.config) + if !RailsMultisite::Railtie.skip_middleware?(app.config) app.middleware.insert_after(ActionDispatch::Executor, RailsMultisite::Middleware) app.middleware.delete(ActionDispatch::Executor) end + ActiveSupport.on_load(:action_controller_base) do + self.config.define_singleton_method(:relative_url_root) do + RailsMultisite::ConnectionManagement.current_path_prefix + end + end + ActiveSupport.on_load(:action_view) do + # config is only set when the ActionView template is needed, prepend a setter. + prepend RailsMultisite::ActionViewHelper + end + if ENV['RAILS_DB'].present? ConnectionManagement.establish_connection(db: ENV['RAILS_DB'], raise_on_missing: true) end end end - def skip_middleware?(config) + def self.skip_middleware?(config) return false if !config.respond_to?(:skip_multisite_middleware) config.skip_multisite_middleware end diff --git a/spec/connection_management_spec.rb b/spec/connection_management_spec.rb index 21bd8f7..35198e0 100644 --- a/spec/connection_management_spec.rb +++ b/spec/connection_management_spec.rb @@ -98,6 +98,17 @@ def with_connection(db) expect(conn.all_dbs).to eq(['default', 'second']) end + it 'finds spec by hostname key' do + spec = conn.connection_spec(key: 'second.localhost') + expect(spec).not_to be_nil + expect(spec.config[:db_key]).to eq('second') + end + + it 'returns nil for an unknown hostname key' do + spec = conn.connection_spec(key: 'unknown.localhost') + expect(spec).to be_nil + end + context 'with second db' do it "is configured correctly" do with_connection('second') do @@ -235,6 +246,84 @@ def with_connection(db) end end + describe 'path-prefix routing' do + before do + conn.config_filename = fixture_path('two_dbs_path_prefix.yml') + end + + it 'finds site_a spec by host + path prefix' do + spec = conn.connection_spec(host: 'example.localhost', path: '/site_a/posts') + expect(spec).not_to be_nil + expect(spec.config[:db_key]).to eq('site_a') + end + + it 'finds site_b spec by host + path prefix' do + spec = conn.connection_spec(host: 'example.localhost', path: '/site_b/topics/1') + expect(spec).not_to be_nil + expect(spec.config[:db_key]).to eq('site_b') + end + + it 'matches when PATH_INFO equals the prefix exactly' do + spec = conn.connection_spec(host: 'example.localhost', path: '/site_a') + expect(spec.config[:db_key]).to eq('site_a') + end + + it 'returns nil for unknown prefix when all sites on that host use path prefixes' do + spec = conn.connection_spec(host: 'example.localhost', path: '/unknown/page') + expect(spec).to be_nil + end + + it 'finds site_a spec by composite key (host/path_prefix)' do + spec = conn.connection_spec(key: 'example.localhost/site_a') + expect(spec).not_to be_nil + expect(spec.config[:db_key]).to eq('site_a') + end + + it 'finds site_b spec by composite key (host/path_prefix)' do + spec = conn.connection_spec(key: 'example.localhost/site_b') + expect(spec).not_to be_nil + expect(spec.config[:db_key]).to eq('site_b') + end + + it 'returns nil for an unknown composite key' do + spec = conn.connection_spec(key: 'example.localhost/unknown') + expect(spec).to be_nil + end + + it 'stores the normalized path_prefix on the spec config' do + spec = conn.connection_spec(host: 'example.localhost', path: '/site_a/posts') + expect(spec.config[:path_prefix]).to eq('/site_a') + end + + it 'returns nil for current_path_prefix on a hostname-only site' do + conn.config_filename = fixture_path('two_dbs.yml') + conn.establish_connection(host: 'second.localhost') + expect(conn.current_path_prefix).to be_nil + end + + it 'returns the path prefix for current_path_prefix when connected to a path-prefix site' do + with_connection('site_a') do + expect(conn.current_path_prefix).to eq('/site_a') + end + end + + it 'returns nil for current_path_prefix when on the default db with no default path prefix' do + with_connection('default') do + expect(conn.current_path_prefix).to be_nil + end + end + + it 'returns the default path prefix for current_path_prefix when on the default db' do + conn.default_path_prefix = '/root' + conn.config_filename = fixture_path('two_dbs_path_prefix.yml') + with_connection('default') do + expect(conn.current_path_prefix).to eq('/root') + end + ensure + conn.default_path_prefix = nil + end + end + describe '.default_connection_handler=' do before do conn.config_filename = fixture_path("two_dbs.yml") diff --git a/spec/fixtures/two_dbs_path_prefix.yml b/spec/fixtures/two_dbs_path_prefix.yml new file mode 100644 index 0000000..ef17959 --- /dev/null +++ b/spec/fixtures/two_dbs_path_prefix.yml @@ -0,0 +1,13 @@ +site_a: + adapter: sqlite3 + database: 'tmp/db_forum_a.test' + host_names: + - example.localhost + path_prefix: /site_a + +site_b: + adapter: sqlite3 + database: 'tmp/db_forum_b.test' + host_names: + - example.localhost + path_prefix: /site_b diff --git a/spec/middleware_spec.rb b/spec/middleware_spec.rb index 0b046a4..c27507f 100644 --- a/spec/middleware_spec.rb +++ b/spec/middleware_spec.rb @@ -33,6 +33,7 @@ def app(config = {}) after do RailsMultisite::ConnectionManagement.clear_settings! + @app = nil end describe '__ws lookup support' do @@ -105,6 +106,119 @@ def app(config = {}) end end + describe 'path-prefix routing' do + before { ActiveRecord::Base.establish_connection } + after { ActiveRecord::Base.remove_connection } + + def app(_config = {}) + RailsMultisite::ConnectionManagement.config_filename = 'spec/fixtures/two_dbs_path_prefix.yml' + + Rack::Builder.new { + use RailsMultisite::Middleware + run(proc do |env| + db = RailsMultisite::ConnectionManagement.current_db + prefix = RailsMultisite::ConnectionManagement.current_path_prefix + [200, + { 'Content-Type' => 'application/json' }, + [{ db: db, path_prefix: prefix, + script_name: env["SCRIPT_NAME"], + path_info: env["PATH_INFO"] }.to_json]] + end) + }.to_app + end + + it 'routes /site_a/* to site_a db' do + get 'http://example.localhost/site_a/slugs' + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body["db"]).to eq("site_a") + expect(body["path_prefix"]).to eq("/site_a") + end + + it 'routes /site_b/* to site_b db' do + get 'http://example.localhost/site_b/slugs/1' + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body["db"]).to eq("site_b") + expect(body["path_prefix"]).to eq("/site_b") + end + + it 'sets SCRIPT_NAME to the matched prefix' do + get 'http://example.localhost/site_a/slugs' + body = JSON.parse(last_response.body) + expect(body["script_name"]).to eq("/site_a") + end + + it 'strips the prefix from PATH_INFO' do + get 'http://example.localhost/site_a/slugs/1' + body = JSON.parse(last_response.body) + expect(body["path_info"]).to eq("/slugs/1") + end + + it 'sets PATH_INFO to / when request hits the prefix root' do + get 'http://example.localhost/site_a' + body = JSON.parse(last_response.body) + expect(body["path_info"]).to eq("/") + end + + it 'returns 404 for unknown prefix on a path-prefix-only hostname' do + get 'http://example.localhost/unknown/page' + expect(last_response).to be_not_found + end + + it 'routes to the correct db via X-MULTISITE-KEY composite key' do + get 'http://boom.com/site_a/posts', {}, { 'HTTP_X_MULTISITE_KEY' => 'example.localhost/site_a' } + expect(last_response).to be_ok + expect(JSON.parse(last_response.body)["db"]).to eq("site_a") + end + + describe 'with default_path_prefix' do + before do + RailsMultisite::ConnectionManagement.default_path_prefix = "/root" + RailsMultisite::ConnectionManagement.config_filename = 'spec/fixtures/two_dbs_path_prefix.yml' + end + + after { RailsMultisite::ConnectionManagement.default_path_prefix = nil } + + it 'routes the relative URL root path to the default db' do + get 'http://default.localhost/root/posts' + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body["db"]).to eq("default") + end + + it 'sets SCRIPT_NAME to the relative URL root' do + get 'http://default.localhost/root/posts' + body = JSON.parse(last_response.body) + expect(body["script_name"]).to eq("/root") + end + + it 'strips the relative URL root from PATH_INFO' do + get 'http://default.localhost/root/posts' + body = JSON.parse(last_response.body) + expect(body["path_info"]).to eq("/posts") + end + + it 'returns 404 for an unrecognized prefix on the default hostname' do + get 'http://default.localhost/unknown/posts' + expect(last_response).to be_not_found + end + + it 'still routes other db path prefixes independently' do + get 'http://example.localhost/site_a/posts' + expect(last_response).to be_ok + body = JSON.parse(last_response.body) + expect(body["db"]).to eq("site_a") + expect(body["script_name"]).to eq("/site_a") + end + + it 'does not nest other db prefixes under the relative URL root' do + get 'http://example.localhost/root/site_a/posts' + expect(last_response).to be_not_found + end + end + end + describe 'encrypted/signed cookie salts' do it 'updates salts per-hostname' do get 'http://default.localhost/salts' diff --git a/spec/railtie_spec.rb b/spec/railtie_spec.rb new file mode 100644 index 0000000..5c6a117 --- /dev/null +++ b/spec/railtie_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true +require 'spec_helper' +require 'rails_multisite' +require 'action_controller' + +describe RailsMultisite::Railtie do + let(:conn) { RailsMultisite::ConnectionManagement } + + # Runs only the RailsMultisite.init initializer without full app boot. + # Use for ConnectionManagement and ActionController assertions — avoids + # the freeze-after-initialize! limitation. + def run_initializer(multisite_fixture: nil, default_path_prefix: nil) + config_path = fixture_path(multisite_fixture) if multisite_fixture + app = Class.new(Rails::Application) do + config.eager_load = false + config.skip_multisite_middleware = true + # Always set explicitly to prevent Rails config inheritance across examples + config.multisite_config_path = config_path + config.multisite_default_path_prefix = default_path_prefix + end + RailsMultisite::Railtie.initializers + .find { |i| i.name == 'RailsMultisite.init' } + .run(app) + app + end + + before do + Rails.application = nil + ActiveRecord::Base.establish_connection + end + + after do + conn.clear_settings! + conn.default_path_prefix = nil + ActiveRecord::Base.remove_connection + Rails.application = nil + if ActionController::Base.config.singleton_class.method_defined?(:relative_url_root) + ActionController::Base.config.singleton_class.remove_method(:relative_url_root) + end + end + + describe 'app.config.multisite' do + it 'is false when no config file is present' do + app = run_initializer + expect(app.config.multisite).to eq(false) + end + + it 'is true when a config file is present' do + app = run_initializer(multisite_fixture: 'two_dbs.yml') + expect(app.config.multisite).to eq(true) + end + end + + describe 'app.config.multisite_config_path' do + it 'loads the specified config file into ConnectionManagement' do + run_initializer(multisite_fixture: 'two_dbs.yml') + expect(conn.all_dbs).to include('second') + end + end + + describe 'app.config.multisite_default_path_prefix' do + it 'passes the value to ConnectionManagement.default_path_prefix' do + run_initializer(multisite_fixture: 'two_dbs_path_prefix.yml', default_path_prefix: '/root') + expect(conn.default_path_prefix).to eq('/root') + end + end + + describe 'ActionController::Base.config.relative_url_root' do + it 'returns the path prefix for a path-prefix site' do + run_initializer(multisite_fixture: 'two_dbs_path_prefix.yml') + conn.establish_connection(db: 'site_a') + expect(ActionController::Base.config.relative_url_root).to eq('/site_a') + end + + it 'returns nil for the default db with no default path prefix' do + run_initializer(multisite_fixture: 'two_dbs_path_prefix.yml') + conn.establish_connection(db: 'default') + expect(ActionController::Base.config.relative_url_root).to be_nil + end + + it 'reflects the correct prefix when switching between sites' do + run_initializer(multisite_fixture: 'two_dbs_path_prefix.yml') + conn.establish_connection(db: 'site_a') + expect(ActionController::Base.config.relative_url_root).to eq('/site_a') + conn.establish_connection(db: 'site_b') + expect(ActionController::Base.config.relative_url_root).to eq('/site_b') + end + + it 'returns the default path prefix when multisite_default_path_prefix is set' do + run_initializer(multisite_fixture: 'two_dbs_path_prefix.yml', default_path_prefix: '/root') + conn.establish_connection(db: 'default') + expect(ActionController::Base.config.relative_url_root).to eq('/root') + end + + it 'returns nil for a hostname-only site' do + run_initializer(multisite_fixture: 'two_dbs.yml') + conn.establish_connection(host: 'second.localhost') + expect(ActionController::Base.config.relative_url_root).to be_nil + end + end +end