├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── Guardfile ├── LICENSE ├── README.md ├── Rakefile ├── gemfiles ├── rails_6.0.gemfile ├── rails_6.1.gemfile ├── rails_7.0.gemfile ├── rails_7.1.gemfile ├── rails_7.2.gemfile └── rails_edge.gemfile ├── lib ├── rails_multisite.rb ├── rails_multisite │ ├── connection_management.rb │ ├── connection_management │ │ ├── null_instance.rb │ │ ├── rails_60_compat.rb │ │ └── rails_61_compat.rb │ ├── cookie_salt.rb │ ├── formatter.rb │ ├── middleware.rb │ ├── railtie.rb │ └── version.rb └── tasks │ ├── db.rake │ └── generators.rake ├── rails_multisite.gemspec └── spec ├── connection_management_spec.rb ├── fixtures ├── database.yml ├── database_without_prepared_statements.yml ├── three_dbs.yml ├── two_dbs.yml └── two_dbs_updated.yml ├── middleware_spec.rb └── spec_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Rails Multisite Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | continue-on-error: ${{ matrix.ok_to_fail == ' - Ok to fail' }} 13 | name: "Ruby ${{ matrix.ruby }} - Rails ${{ matrix.rails }}${{ matrix.ok_to_fail }}" 14 | env: 15 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_${{ matrix.rails }}.gemfile 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | ruby: ["3.1", "3.2", "3.3", "3.4"] 20 | rails: ["6.1", "7.0", "7.1", "7.2", "edge"] 21 | exclude: 22 | - ruby: "3.4" 23 | rails: "6.1" 24 | - ruby: "3.4" 25 | rails: "7.0" 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{ matrix.ruby }} 31 | bundler-cache: true 32 | - name: Rubocop 33 | run: bundle exec rubocop 34 | - name: Tests 35 | run: bundle exec rspec 36 | 37 | publish: 38 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 39 | needs: build 40 | runs-on: ubuntu-latest 41 | 42 | steps: 43 | - uses: actions/checkout@v2 44 | - name: Release Gem 45 | uses: discourse/publish-rubygems-action@v3 46 | env: 47 | RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} 48 | GIT_EMAIL: team@discourse.org 49 | GIT_NAME: discoursebot 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | .byebug_history 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | 20 | .rubocop-https---raw-githubusercontent-com-discourse-discourse-master--rubocop-yml 21 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-discourse: default.yml 3 | AllCops: 4 | Exclude: 5 | - gemfiles/vendor/bundle/**/* 6 | - vendor/bundle/**/* 7 | 8 | Discourse/Plugins: 9 | Enabled: false 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [6.1.0] - 2024-08-13 9 | 10 | ### Added 11 | 12 | - Support for Rails 7.2+ 13 | 14 | ## [6.0.0] - 2024-03-14 15 | 16 | ### Added 17 | 18 | - Bump minimum Ruby support to >= 3.1 since Ruby 3.0 is EOL on 2024-03-31. 19 | 20 | ## [5.0.1] - 2024-03-12 21 | 22 | - Small refactor of ConnectionManagement 23 | - Drop support for Rails < 6.1 24 | 25 | ## [5.0.0] - 2023-05-31 26 | 27 | - Add support for Rails 7.1+ 28 | - Drop support for Ruby < 3.0 29 | - Drop support for Rails < 6.0 30 | 31 | ## [4.0.1] - 2022-01-13 32 | 33 | - Add support for Rails 7.0+ 34 | 35 | ## [4.0.0] - 2021-11-15 36 | 37 | - Vary the encrypted/signed cookie salts per-hostname (fix for CVE-2021-41263). This update will 38 | cause existing cookies to be invalidated 39 | 40 | ## [3.1.0] - 2021-09-10 41 | 42 | - Make config file path configurable via `Rails.configuration.multisite_config_path` 43 | 44 | ## [3.0.0] - 2021-03-24 45 | 46 | - First version to support Rails 6.1 / 7 47 | - Removed support for Ruby 2.4 which is no longer maintained 48 | 49 | ## [2.4.0] - 2020-09-15 50 | 51 | - **ws parameter is only supported for RailsMultisite::ConnectionManagement.asset_hostname 52 | previously we would support this for any hostname and careful attackers could use this 53 | maliciously. Additionally, if **ws is used we will always strip request cookies as an 54 | extra security measure. 55 | 56 | ## [2.3.0] - 2020-06-10 57 | 58 | - Allow the default connection handler to be changed. 59 | 60 | ## [2.2.2] - 2020-06-02 61 | 62 | - Use `ActiveRecord::Base.connection_handlers` to keep track of all connection handlers. 63 | 64 | ## [2.1.2] - 2020-05-08 65 | 66 | - Add support for `Rails.configuration.skip_multisite_middleware`, if configured railstie will avoid 67 | all configuration of middleware. 68 | 69 | ## [2.1.1] - 2020-03-13 70 | 71 | - Add `current_db_hostnames` to get a listing of current db hostnames 72 | 73 | ## [2.1.0] - 2020-02-28 74 | 75 | - When reloading, only update changed connection specs. This means that ActiveRecord can keep the existing SchemaCache for unchanged connections 76 | - Remove support for Rails 4 77 | - Remove support for Ruby 2.3 78 | 79 | ## [2.0.7] - 2019-04-29 80 | 81 | - Add support for Rails 6 82 | - Remove support for Ruby 2.2 as it is EOL 83 | 84 | ## [2.0.6] - 2019-01-23 85 | 86 | - Fixed a bug where calling `RailsMultisite::ConnectionManagement#establish_connection` 87 | with a `db: default, raise_on_missing: true` would raise an error. 88 | 89 | ## [2.0.5] - YANKED 90 | 91 | ## [2.0.4] - 2018-02-12 92 | 93 | - Fix bug where calling `RailsMultisite::ConnectionManagement.current_hostname` 94 | with a `default` connection would throw an undefined method error. 95 | 96 | ## [2.0.3] - yanked 97 | 98 | - Base `RailsMultisite::ConnectionManagement.current_hostname` on `@host_spec_cache`. 99 | 100 | ## [2.0.2] 101 | 102 | - with_connection should return result of block 103 | 104 | ## [1.1.2] 105 | 106 | - raise error if RAILS_DB is specified yet missing 107 | 108 | ## [1.1.1] 109 | 110 | - allows db_lookup callback for middleware, this allows you to whitelist paths in multisite 111 | 112 | ## [1.0.6] 113 | 114 | - Revert deprecation fix because it can break multisite in subtle ways. 115 | - Allow `db` to be passed as a symbol to `RailsMultisite::ConnectionManagement.establish_connection`. 116 | 117 | ## [1.0.5] 118 | 119 | - Fix deprecation warnings. 120 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source 'https://rubygems.org' 3 | 4 | def rails_master? 5 | ENV["RAILS_MASTER"] == '1' 6 | end 7 | 8 | if rails_master? 9 | gem 'arel', git: 'https://github.com/rails/arel.git' 10 | gem 'rails', git: 'https://github.com/rails/rails.git', branch: 'main' 11 | end 12 | 13 | group :development, :test do 14 | gem 'byebug' 15 | gem 'rubocop-discourse' 16 | end 17 | 18 | group :test do 19 | gem 'rspec' 20 | gem 'sqlite3' 21 | end 22 | 23 | group :development do 24 | gem 'guard' 25 | gem 'guard-rspec' 26 | end 27 | 28 | # Specify your gem's dependencies in rails_multisite.gemspec 29 | gemspec 30 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # A sample Guardfile 3 | # More info at https://github.com/guard/guard#readme 4 | 5 | guard :rspec, cmd: 'bundle exec rspec' do 6 | watch(%r{^spec/.+_spec\.rb$}) 7 | watch(%r{^lib/rails_multisite/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 8 | watch('spec/spec_helper.rb') { "spec" } 9 | end 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Sam Saffron 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rails Multisite 2 | 3 | This gem provides multi-db support for Rails applications. 4 | 5 | Using its middleware you can partition your app so each hostname has its own db. 6 | 7 | It provides a series of helper for working with multiple database, and some additional rails tasks for working with them. 8 | 9 | It was extracted from Discourse. https://discourse.org 10 | 11 | ## Installation 12 | 13 | Add this line to your application's Gemfile: 14 | 15 | gem 'rails_multisite' 16 | 17 | And then execute: 18 | 19 | $ bundle 20 | 21 | Or install it yourself as: 22 | 23 | $ gem install rails_multisite 24 | 25 | ## Usage 26 | 27 | Configuration requires a file called: `config/multisite.yml` that specifies connection specs for all dbs. 28 | 29 | ``` 30 | mlp: 31 | adapter: postgresql 32 | database: discourse_mlp 33 | username: discourse_mlp 34 | password: applejack 35 | host: dbhost 36 | pool: 5 37 | timeout: 5000 38 | host_names: 39 | - discourse.equestria.com 40 | - discourse.equestria.internal 41 | 42 | drwho: 43 | adapter: postgresql 44 | database: discourse_who 45 | username: discourse_who 46 | password: "Up the time stream without a TARDIS" 47 | host: dbhost 48 | pool: 5 49 | timeout: 5000 50 | host_names: 51 | - discuss.tardis.gallifrey 52 | ``` 53 | 54 | 55 | ### Execute a query on each connection 56 | 57 | ``` 58 | RailsMultisite::ConnectionManagement.each_connection do |db| 59 | # run query in context of db 60 | # eg: User.find(1) 61 | end 62 | ``` 63 | 64 | ``` 65 | RailsMultisite::ConnectionManagement.each_connection(threads: 5) do |db| 66 | # run query in context of db, will do so in a thread pool of 5 threads 67 | # if any query fails an exception will be raised 68 | # eg: User.find(1) 69 | end 70 | ``` 71 | 72 | ### Usage with Rails 73 | 74 | #### `RAILS_DB` Environment Variable 75 | 76 | When working with a Rails application, you can specify the DB that you'll like to work with by specifying the `RAILS_DB` ENV variable. 77 | 78 | ``` 79 | # config/multisite.yml 80 | 81 | db_one: 82 | adapter: ... 83 | database: some_database_1 84 | 85 | db_two: 86 | adapater: ... 87 | database: some_database_2 88 | ``` 89 | 90 | To get a Rails console that is connected to `some_database_1` database: 91 | 92 | ``` 93 | RAILS_DB=db_one rails console 94 | ``` 95 | 96 | ### CDN origin support 97 | 98 | To avoid needing to configure many origins you can consider using `RailsMultisite::ConnectionManagement.asset_hostnames` 99 | 100 | When configured, requests to `asset_hostname`?__ws=another.host.name will be re-routed to the correct site. Cookies will 101 | be stripped on all incoming requests. 102 | 103 | Example: 104 | 105 | - Multisite serves `sub.example.com` and `assets.example.com` 106 | - `RailsMultisite::ConnectionManagement.asset_hostnames = ['assets.example.com']` 107 | - Requests to `https://assets.example.com/route/?__ws=sub.example.com` will be routed to the `sub.example.com` 108 | 109 | 110 | ## Contributing 111 | 112 | 1. Fork it 113 | 2. Create your feature branch (`git checkout -b my-new-feature`) 114 | 3. Commit your changes (`git commit -am 'Added some feature'`) 115 | 4. Push to the branch (`git push origin my-new-feature`) 116 | 5. Create new Pull Request 117 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | # frozen_string_literal: true 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:test) do |spec| 7 | spec.pattern = 'spec/*_spec.rb' 8 | end 9 | -------------------------------------------------------------------------------- /gemfiles/rails_6.0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source 'https://rubygems.org' 3 | 4 | group :test do 5 | gem 'activerecord', '> 6', '< 6.1' 6 | gem 'railties', '> 6', '< 6.1' 7 | gem 'rspec' 8 | gem 'sqlite3', '~> 1.4' 9 | gem 'byebug' 10 | gem 'rubocop' 11 | gem 'rubocop-discourse' 12 | end 13 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source 'https://rubygems.org' 3 | 4 | group :test do 5 | gem 'activerecord', '> 6', '< 7' 6 | gem 'railties', '> 6', '< 7' 7 | gem 'rspec' 8 | gem 'sqlite3', '~> 1.4' 9 | gem 'byebug' 10 | gem 'rubocop' 11 | gem 'rubocop-discourse' 12 | end 13 | -------------------------------------------------------------------------------- /gemfiles/rails_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source 'https://rubygems.org' 3 | 4 | group :test do 5 | gem 'activerecord', '> 7', '< 7.1' 6 | gem 'railties', '> 7', '< 7.1' 7 | gem 'rspec' 8 | gem 'sqlite3', '~> 1.4' 9 | gem 'byebug' 10 | gem 'rubocop' 11 | gem 'rubocop-discourse' 12 | end 13 | -------------------------------------------------------------------------------- /gemfiles/rails_7.1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source 'https://rubygems.org' 3 | 4 | group :test do 5 | gem 'activerecord', '~> 7.1.0' 6 | gem 'railties', '~> 7.1.0' 7 | gem 'rspec' 8 | gem 'sqlite3', '~> 1.4' 9 | gem 'byebug' 10 | gem 'rubocop' 11 | gem 'rubocop-discourse' 12 | end 13 | -------------------------------------------------------------------------------- /gemfiles/rails_7.2.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source 'https://rubygems.org' 3 | 4 | group :test do 5 | gem 'activerecord', '~> 7.2.0' 6 | gem 'railties', '~> 7.2.0' 7 | gem 'rspec' 8 | gem 'sqlite3', '~> 1.4' 9 | gem 'byebug' 10 | gem 'rubocop' 11 | gem 'rubocop-discourse' 12 | end 13 | -------------------------------------------------------------------------------- /gemfiles/rails_edge.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source 'https://rubygems.org' 3 | 4 | group :test do 5 | gem 'arel', git: 'https://github.com/rails/arel.git' 6 | gem 'rails', git: 'https://github.com/rails/rails.git', branch: 'main' 7 | gem 'rspec' 8 | gem 'sqlite3' 9 | gem 'byebug' 10 | gem 'rubocop' 11 | gem 'rubocop-discourse' 12 | end 13 | -------------------------------------------------------------------------------- /lib/rails_multisite.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_record' 4 | require 'rails' 5 | require 'rails_multisite/version' 6 | require 'rails_multisite/railtie' 7 | require 'rails_multisite/formatter' 8 | require 'rails_multisite/connection_management' 9 | require 'rails_multisite/middleware' 10 | require 'rails_multisite/cookie_salt' 11 | -------------------------------------------------------------------------------- /lib/rails_multisite/connection_management.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if Rails.version >= "6.1" 4 | require "rails_multisite/connection_management/rails_61_compat" 5 | else 6 | require "rails_multisite/connection_management/rails_60_compat" 7 | end 8 | require "rails_multisite/connection_management/null_instance" 9 | 10 | module RailsMultisite 11 | class ConnectionManagement 12 | DEFAULT = "default" 13 | 14 | cattr_accessor :connection_handlers, default: {} 15 | 16 | attr_reader :config_filename, :db_spec_cache 17 | 18 | class << self 19 | attr_accessor :asset_hostnames 20 | 21 | delegate :all_dbs, 22 | :config_filename, 23 | :connection_spec, 24 | :current_db, 25 | :default_connection_handler=, 26 | :each_connection, 27 | :establish_connection, 28 | :has_db?, 29 | :host, 30 | :reload, 31 | :with_connection, 32 | :with_hostname, 33 | to: :instance 34 | 35 | def default_config_filename 36 | File.absolute_path(Rails.root.to_s + "/config/multisite.yml") 37 | end 38 | 39 | def clear_settings! 40 | instance.clear_settings! 41 | @instance = nil 42 | end 43 | 44 | def load_settings! 45 | # no op only here backwards compat 46 | STDERR.puts "RailsMultisite::ConnectionManagement.load_settings! is deprecated" 47 | end 48 | 49 | def instance 50 | @instance || NullInstance.instance 51 | end 52 | 53 | def config_filename=(config_filename) 54 | if config_filename.blank? 55 | @instance = nil 56 | else 57 | @instance = new(config_filename) 58 | end 59 | end 60 | 61 | def current_hostname 62 | current_db_hostnames.first 63 | end 64 | 65 | def current_db_hostnames 66 | config = 67 | ( 68 | connection_spec(db: current_db) || ConnectionSpecification.current 69 | ).config 70 | config[:host_names] || [config[:host]] 71 | end 72 | 73 | def handler_key(spec) 74 | @handler_key_suffix ||= 75 | begin 76 | if ActiveRecord.respond_to?(:writing_role) 77 | "_#{ActiveRecord.writing_role}" 78 | elsif ActiveRecord::Base.respond_to?(:writing_role) 79 | "_#{ActiveRecord::Base.writing_role}" 80 | else 81 | "" 82 | end 83 | end 84 | 85 | :"#{spec.name}#{@handler_key_suffix}" 86 | end 87 | end 88 | 89 | def initialize(config_filename) 90 | @config_filename = config_filename 91 | 92 | @db_spec_cache = {} 93 | @default_spec = ConnectionSpecification.default 94 | @default_connection_handler = ActiveRecord::Base.connection_handler 95 | 96 | @reload_mutex = Mutex.new 97 | 98 | load_config! 99 | end 100 | 101 | def load_config! 102 | configs = YAML.safe_load(File.open(config_filename)) 103 | 104 | no_prepared_statements = 105 | @default_spec.config[:prepared_statements] == false 106 | 107 | configs.each do |k, v| 108 | if k == DEFAULT 109 | raise ArgumentError.new("Please do not name any db default!") 110 | end 111 | v[:db_key] = k 112 | v[:prepared_statements] = false if no_prepared_statements 113 | end 114 | 115 | # Build a hash of db name => spec 116 | new_db_spec_cache = ConnectionSpecification.db_spec_cache(configs) 117 | new_db_spec_cache.each do |k, v| 118 | # If spec already existed, use the old version 119 | if v&.to_hash == db_spec_cache[k]&.to_hash 120 | new_db_spec_cache[k] = db_spec_cache[k] 121 | end 122 | end 123 | 124 | # Build a hash of hostname => spec 125 | new_host_spec_cache = {} 126 | configs.each do |k, v| 127 | next unless v["host_names"] 128 | v["host_names"].each do |host| 129 | new_host_spec_cache[host] = new_db_spec_cache[k] 130 | end 131 | end 132 | 133 | # Add the default hostnames as well 134 | @default_spec.config[:host_names].each do |host| 135 | new_host_spec_cache[host] = @default_spec 136 | end 137 | 138 | removed_dbs = db_spec_cache.keys - new_db_spec_cache.keys 139 | removed_specs = db_spec_cache.values_at(*removed_dbs) 140 | 141 | @host_spec_cache = new_host_spec_cache 142 | @db_spec_cache = new_db_spec_cache 143 | 144 | # Clean up connection handler cache. 145 | removed_specs.each { |s| connection_handlers.delete(handler_key(s)) } 146 | end 147 | 148 | def reload 149 | @reload_mutex.synchronize { load_config! } 150 | end 151 | 152 | def has_db?(db) 153 | db == DEFAULT || !!db_spec_cache[db] 154 | end 155 | 156 | def establish_connection(opts) 157 | opts[:db] = opts[:db].to_s 158 | 159 | if opts[:db] != DEFAULT 160 | spec = connection_spec(opts) 161 | 162 | if (!spec && opts[:raise_on_missing]) 163 | raise "ERROR: #{opts[:db]} not found!" 164 | end 165 | end 166 | 167 | spec ||= @default_spec 168 | handler = nil 169 | if spec != @default_spec 170 | handler = connection_handlers[handler_key(spec)] 171 | unless handler 172 | handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new 173 | handler.establish_connection(spec.config) 174 | connection_handlers[handler_key(spec)] = handler 175 | end 176 | else 177 | handler = @default_connection_handler 178 | end 179 | 180 | ActiveRecord::Base.connection_handler = handler 181 | end 182 | 183 | def with_hostname(hostname) 184 | old = current_hostname 185 | connected = ActiveRecord::Base.connection_pool.connected? 186 | 187 | establish_connection(host: hostname) unless connected && hostname == old 188 | rval = yield hostname 189 | 190 | unless connected && hostname == old 191 | ActiveRecord::Base.connection_handler.clear_active_connections! 192 | 193 | establish_connection(host: old) 194 | unless connected 195 | ActiveRecord::Base.connection_handler.clear_active_connections! 196 | end 197 | end 198 | 199 | rval 200 | end 201 | 202 | def with_connection(db = DEFAULT) 203 | old = current_db 204 | connected = ActiveRecord::Base.connection_pool.connected? 205 | 206 | establish_connection(db: db) unless connected && db == old 207 | rval = yield db 208 | 209 | unless connected && db == old 210 | ActiveRecord::Base.connection_handler.clear_active_connections! 211 | 212 | establish_connection(db: old) 213 | unless connected 214 | ActiveRecord::Base.connection_handler.clear_active_connections! 215 | end 216 | end 217 | 218 | rval 219 | end 220 | 221 | def each_connection(opts = nil, &blk) 222 | old = current_db 223 | connected = ActiveRecord::Base.connection_pool.connected? 224 | 225 | queue = nil 226 | threads = nil 227 | 228 | if (opts && (threads = opts[:threads])) 229 | queue = Queue.new 230 | all_dbs.each { |db| queue << db } 231 | end 232 | 233 | errors = nil 234 | 235 | if queue 236 | threads 237 | .times 238 | .map do 239 | Thread.new do 240 | while true 241 | begin 242 | db = queue.deq(true) 243 | rescue ThreadError 244 | db = nil 245 | end 246 | 247 | break unless db 248 | 249 | establish_connection(db: db) 250 | # no choice but to rescue, should probably log 251 | 252 | begin 253 | blk.call(db) 254 | rescue => e 255 | (errors ||= []) << e 256 | end 257 | ActiveRecord::Base.connection_handler.clear_active_connections! 258 | end 259 | end 260 | end 261 | .map(&:join) 262 | else 263 | all_dbs.each do |db| 264 | establish_connection(db: db) 265 | blk.call(db) 266 | ActiveRecord::Base.connection_handler.clear_active_connections! 267 | end 268 | end 269 | 270 | if errors && errors.length > 0 271 | raise StandardError, "Failed to run queries #{errors.inspect}" 272 | end 273 | ensure 274 | establish_connection(db: old) 275 | unless connected 276 | ActiveRecord::Base.connection_handler.clear_active_connections! 277 | end 278 | end 279 | 280 | def all_dbs 281 | [DEFAULT] + db_spec_cache.keys.to_a 282 | end 283 | 284 | def current_db 285 | ConnectionSpecification.current.config[:db_key] || DEFAULT 286 | end 287 | 288 | def current_hostname 289 | ConnectionManagement.current_hostname 290 | end 291 | 292 | def host(env) 293 | if host = env["RAILS_MULTISITE_HOST"] 294 | return host 295 | end 296 | 297 | request = Rack::Request.new(env) 298 | 299 | host = 300 | if request.params["__ws"] && 301 | self.class.asset_hostnames&.include?(request.host) 302 | request.cookies.clear 303 | request.params["__ws"] 304 | else 305 | request.host 306 | end 307 | 308 | env["RAILS_MULTISITE_HOST"] = host 309 | end 310 | 311 | def connection_spec(opts) 312 | opts[:host] ? @host_spec_cache[opts[:host]] : db_spec_cache[opts[:db]] 313 | end 314 | 315 | def clear_settings! 316 | db_spec_cache.each do |key, spec| 317 | connection_handlers.delete(handler_key(spec)) 318 | end 319 | end 320 | 321 | def default_connection_handler=(connection_handler) 322 | unless connection_handler.is_a?( 323 | ActiveRecord::ConnectionAdapters::ConnectionHandler 324 | ) 325 | raise ArgumentError.new("Invalid connection handler") 326 | end 327 | 328 | @default_connection_handler = connection_handler 329 | end 330 | 331 | private 332 | 333 | def handler_key(spec) 334 | self.class.handler_key(spec) 335 | end 336 | end 337 | end 338 | -------------------------------------------------------------------------------- /lib/rails_multisite/connection_management/null_instance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsMultisite 4 | class ConnectionManagement 5 | class NullInstance 6 | include Singleton 7 | 8 | def clear_settings! 9 | end 10 | 11 | def config_filename 12 | end 13 | 14 | def default_connection_handler=(_connection_handler) 15 | end 16 | 17 | def establish_connection(_opts) 18 | end 19 | 20 | def reload 21 | end 22 | 23 | def all_dbs 24 | [DEFAULT] 25 | end 26 | 27 | def connection_spec(_opts) 28 | ConnectionSpecification.current 29 | end 30 | 31 | def current_db 32 | DEFAULT 33 | end 34 | 35 | def each_connection(_opts = nil, &blk) 36 | with_connection(&blk) 37 | end 38 | 39 | def has_db?(db) 40 | db == DEFAULT 41 | end 42 | 43 | def host(env) 44 | env["HTTP_HOST"] 45 | end 46 | 47 | def with_connection(db = DEFAULT, &blk) 48 | connected = ActiveRecord::Base.connection_pool.connected? 49 | result = blk.call(db) 50 | unless connected 51 | ActiveRecord::Base.connection_handler.clear_active_connections! 52 | end 53 | result 54 | end 55 | 56 | def with_hostname(hostname, &blk) 57 | blk.call(hostname) 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/rails_multisite/connection_management/rails_60_compat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsMultisite 4 | class ConnectionManagement 5 | class ConnectionSpecification 6 | class << self 7 | def current 8 | ActiveRecord::Base.connection_pool.spec 9 | end 10 | 11 | def db_spec_cache(configs) 12 | resolve_configs = configs 13 | # rails 6 needs to use a proper object for the resolver 14 | if defined?(ActiveRecord::DatabaseConfigurations) 15 | resolve_configs = ActiveRecord::DatabaseConfigurations.new(configs) 16 | end 17 | resolver = ActiveRecord::ConnectionAdapters::ConnectionSpecification::Resolver.new(resolve_configs) 18 | configs.map { |k, _| [k, resolver.spec(k.to_sym)] }.to_h 19 | end 20 | 21 | def default 22 | ActiveRecord::ConnectionAdapters::ConnectionSpecification::Resolver 23 | .new(ActiveRecord::Base.configurations) 24 | .spec(Rails.env.to_sym) 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/rails_multisite/connection_management/rails_61_compat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsMultisite 4 | class ConnectionManagement 5 | class ConnectionSpecification 6 | class << self 7 | def current 8 | new(ActiveRecord::Base.connection_pool.db_config) 9 | end 10 | 11 | def db_spec_cache(configs) 12 | resolve_configs = ActiveRecord::DatabaseConfigurations.new(configs) 13 | configs.map { |k, _| [k, new(resolve_configs.resolve(k.to_sym))] }.to_h 14 | end 15 | 16 | def default 17 | new(ActiveRecord::Base.configurations.resolve(Rails.env.to_sym)) 18 | end 19 | end 20 | 21 | attr_reader :spec 22 | 23 | def initialize(spec) 24 | @spec = spec 25 | end 26 | 27 | def name 28 | spec.env_name 29 | end 30 | 31 | def to_hash 32 | spec.configuration_hash 33 | end 34 | alias config to_hash 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/rails_multisite/cookie_salt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsMultisite 4 | class CookieSalt 5 | COOKIE_SALT_KEYS = [ 6 | "action_dispatch.signed_cookie_salt", 7 | "action_dispatch.encrypted_cookie_salt", 8 | "action_dispatch.encrypted_signed_cookie_salt", 9 | "action_dispatch.authenticated_encrypted_cookie_salt" 10 | ] 11 | 12 | def self.update_cookie_salts(env:, host:) 13 | COOKIE_SALT_KEYS.each { |key| env[key] = "#{env[key]} #{host}" } 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/rails_multisite/formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # 3 | module RailsMultisite 4 | class Formatter < ::ActiveSupport::Logger::SimpleFormatter 5 | include ::ActiveSupport::TaggedLogging::Formatter 6 | 7 | def call(severity, timestamp, progname, msg) 8 | "[#{RailsMultisite::ConnectionManagement.current_db}] #{super}" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/rails_multisite/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | module RailsMultisite 3 | class Middleware 4 | def initialize(app, config = nil) 5 | @app = app 6 | @db_lookup = config && config[:db_lookup] 7 | end 8 | 9 | def call(env) 10 | host = ConnectionManagement.host(env) 11 | db = nil 12 | begin 13 | 14 | unless ConnectionManagement.connection_spec(host: host) 15 | db = @db_lookup && @db_lookup.call(env) 16 | if db 17 | host = nil 18 | else 19 | return [404, {}, ["not found"]] 20 | end 21 | end 22 | 23 | ActiveRecord::Base.connection_handler.clear_active_connections! 24 | ConnectionManagement.establish_connection(host: host, db: db) 25 | CookieSalt.update_cookie_salts(env: env, host: host) 26 | @app.call(env) 27 | ensure 28 | ActiveRecord::Base.connection_handler.clear_active_connections! 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/rails_multisite/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsMultisite 4 | class Railtie < Rails::Railtie 5 | rake_tasks do 6 | Dir[File.join(File.dirname(__FILE__), '../tasks/*.rake')].each { |f| load f } 7 | end 8 | 9 | initializer "RailsMultisite.init" do |app| 10 | app.config.multisite = false 11 | 12 | config_file = 13 | app.config.respond_to?(:multisite_config_path) && 14 | app.config.multisite_config_path.presence 15 | 16 | config_file ||= ConnectionManagement.default_config_filename 17 | 18 | if File.exist?(config_file) 19 | ConnectionManagement.config_filename = config_file 20 | app.config.multisite = true 21 | Rails.logger.formatter = RailsMultisite::Formatter.new 22 | 23 | if !skip_middleware?(app.config) 24 | app.middleware.insert_after(ActionDispatch::Executor, RailsMultisite::Middleware) 25 | app.middleware.delete(ActionDispatch::Executor) 26 | end 27 | 28 | if ENV['RAILS_DB'].present? 29 | ConnectionManagement.establish_connection(db: ENV['RAILS_DB'], raise_on_missing: true) 30 | end 31 | end 32 | end 33 | 34 | def skip_middleware?(config) 35 | return false if !config.respond_to?(:skip_multisite_middleware) 36 | config.skip_multisite_middleware 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/rails_multisite/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # 3 | module RailsMultisite 4 | VERSION = "6.1.0" 5 | end 6 | -------------------------------------------------------------------------------- /lib/tasks/db.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | desc "migrate all sites in tier" 3 | task "multisite:migrate" => :environment do 4 | RailsMultisite::ConnectionManagement.each_connection do |db| 5 | puts "Migrating #{db}" 6 | puts "---------------------------------\n" 7 | t = Rake::Task["db:migrate"] 8 | t.reenable 9 | t.invoke 10 | end 11 | end 12 | 13 | task "multisite:seed_fu" => :environment do 14 | RailsMultisite::ConnectionManagement.each_connection do |db| 15 | puts "Seeding #{db}" 16 | puts "---------------------------------\n" 17 | t = Rake::Task["db:seed_fu"] 18 | t.reenable 19 | t.invoke 20 | end 21 | end 22 | 23 | desc "rollback migrations for all sites in tier" 24 | task "multisite:rollback" => :environment do 25 | RailsMultisite::ConnectionManagement.each_connection do |db| 26 | puts "Rollback #{db}" 27 | puts "---------------------------------\n" 28 | t = Rake::Task["db:rollback"] 29 | t.reenable 30 | t.invoke 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/tasks/generators.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | desc "generate multisite config file (if missing)" 3 | task "multisite:generate:config" => :environment do 4 | filename = RailsMultisite::ConnectionManagement.config_filename 5 | 6 | if File.exist?(filename) 7 | puts "Config is already generated at #{RailsMultisite::ConnectionManagement::CONFIG_FILE}" 8 | else 9 | puts "Generated config file at #{RailsMultisite::ConnectionManagement::CONFIG_FILE}" 10 | File.open(filename, 'w') do |f| 11 | f.write <<-CONFIG 12 | # site_name: 13 | # adapter: postgresql 14 | # database: db_name 15 | # host: localhost 16 | # pool: 5 17 | # timeout: 5000 18 | # host_names: 19 | # - www.mysite.com 20 | # - www.anothersite.com 21 | CONFIG 22 | 23 | end 24 | 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /rails_multisite.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # -*- encoding: utf-8 -*- 3 | require File.expand_path('../lib/rails_multisite/version', __FILE__) 4 | 5 | Gem::Specification.new do |gem| 6 | gem.authors = ["Sam Saffron"] 7 | gem.email = ["sam.saffron@gmail.com"] 8 | gem.description = %q{Multi tenancy support for Rails} 9 | gem.summary = %q{Multi tenancy support for Rails} 10 | gem.homepage = "" 11 | 12 | # when this is extracted comment it back in, prd has no .git 13 | # gem.files = `git ls-files`.split($\) 14 | gem.files = Dir['README*', 'LICENSE', 'lib/**/*.rb', 'lib/**/*.rake'] 15 | 16 | gem.name = "rails_multisite" 17 | gem.require_paths = ["lib"] 18 | gem.version = RailsMultisite::VERSION 19 | 20 | gem.required_ruby_version = ">=3.1" 21 | 22 | gem.add_dependency "activerecord", ">= 6.0" 23 | gem.add_dependency "railties", ">= 6.0" 24 | end 25 | -------------------------------------------------------------------------------- /spec/connection_management_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'spec_helper' 3 | require 'rails_multisite' 4 | 5 | class Person < ActiveRecord::Base; end 6 | 7 | describe RailsMultisite::ConnectionManagement do 8 | 9 | let(:conn) { RailsMultisite::ConnectionManagement } 10 | 11 | before do 12 | ActiveRecord::Base.establish_connection 13 | end 14 | 15 | after do 16 | conn.clear_settings! 17 | ActiveRecord::Base.remove_connection 18 | end 19 | 20 | def with_connection(db) 21 | original_connection_handler = ActiveRecord::Base.connection_handler 22 | original_connection_handler.clear_active_connections! 23 | conn.establish_connection(db: db) 24 | yield ActiveRecord::Base.connection.raw_connection 25 | ensure 26 | ActiveRecord::Base.connection_handler.clear_active_connections! 27 | ActiveRecord::Base.connection_handler = original_connection_handler 28 | end 29 | 30 | context 'with default' do 31 | it 'has correct all_dbs' do 32 | expect(conn.all_dbs).to eq(['default']) 33 | end 34 | 35 | context 'with current' do 36 | it "has default current db" do 37 | expect(conn.current_db).to eq('default') 38 | end 39 | 40 | it "has current hostname" do 41 | expect(conn.current_hostname).to eq('default.localhost') 42 | end 43 | end 44 | 45 | it 'yields self for with_connection' do 46 | x = conn.with_connection("default") do 47 | "hi" 48 | end 49 | 50 | expect(x).to eq("hi") 51 | end 52 | 53 | end 54 | 55 | it "inherits prepared_statements" do 56 | load_db_config("database_without_prepared_statements.yml") 57 | conn.config_filename = fixture_path("two_dbs.yml") 58 | expect(conn.connection_spec(db: "second").config[:prepared_statements]).to be(false) 59 | load_db_config("database.yml") 60 | end 61 | 62 | context 'with two dbs' do 63 | before do 64 | conn.config_filename = fixture_path("two_dbs.yml") 65 | end 66 | 67 | it 'accepts a symbol for the db name' do 68 | with_connection(:second) do 69 | expect(conn.current_db).to eq('second') 70 | end 71 | 72 | expect(conn.current_db).to eq('default') 73 | end 74 | 75 | it "can exectue a queries concurrently per db" do 76 | threads = Set.new 77 | 78 | conn.each_connection(threads: 2) do 79 | sleep 0.002 80 | threads << Thread.current.object_id 81 | end 82 | 83 | expect(threads.length).to eq(2) 84 | end 85 | 86 | it "correctly handles exceptions in threads mode" do 87 | 88 | begin 89 | conn.each_connection(threads: 2) do 90 | boom 91 | end 92 | rescue => e 93 | expect(e.to_s).to include("boom") 94 | end 95 | end 96 | 97 | it "has correct all_dbs" do 98 | expect(conn.all_dbs).to eq(['default', 'second']) 99 | end 100 | 101 | context 'with second db' do 102 | it "is configured correctly" do 103 | with_connection('second') do 104 | expect(conn.current_db).to eq('second') 105 | expect(conn.current_hostname).to eq("second.localhost") 106 | end 107 | end 108 | end 109 | 110 | context 'with data partitioning' do 111 | after do 112 | ['default', 'second'].each do |db| 113 | with_connection(db) do |cnn| 114 | cnn.execute("drop table people") rescue nil 115 | end 116 | end 117 | end 118 | 119 | it 'partitions data correctly' do 120 | 121 | ['default', 'second'].map do |db| 122 | 123 | with_connection(db) do |cnn| 124 | cnn.execute("create table if not exists people(id INTEGER PRIMARY KEY AUTOINCREMENT, db)") 125 | end 126 | end 127 | 128 | SQLite3::Database.query_log.clear 129 | 130 | 5.times do 131 | ['default', 'second'].map do |db| 132 | Thread.new do 133 | with_connection(db) do |cnn| 134 | Person.create!(db: db) 135 | end 136 | end 137 | end.map(&:join) 138 | end 139 | 140 | lists = [] 141 | ['default', 'second'].each do |db| 142 | with_connection(db) do |cnn| 143 | lists << Person.order(:id).to_a.map { |p| [p.id, p.db] } 144 | end 145 | end 146 | 147 | expect(lists[1]).to eq((1..5).map { |id| [id, "second"] }) 148 | expect(lists[0]).to eq((1..5).map { |id| [id, "default"] }) 149 | 150 | end 151 | end 152 | 153 | describe "reloading" do 154 | context "when config is unchanged" do 155 | it "maintains the same connection handlers" do 156 | default_spec = conn.connection_spec(db: "default") 157 | second_spec = conn.connection_spec(db: "second") 158 | 159 | conn.reload 160 | 161 | expect(default_spec).to eq(conn.connection_spec(db: "default")) 162 | expect(second_spec).to eq(conn.connection_spec(db: "second")) 163 | end 164 | end 165 | 166 | context "when site config is updated" do 167 | it "updates that connection handler" do 168 | default_spec = conn.connection_spec(db: "default") 169 | second_spec = conn.connection_spec(db: "second") 170 | 171 | conn.instance.instance_variable_set(:@config_filename, fixture_path("two_dbs_updated.yml")) 172 | conn.reload 173 | 174 | expect(default_spec).to eq(conn.connection_spec(db: "default")) 175 | expect(second_spec).not_to eq(conn.connection_spec(db: "second")) 176 | end 177 | end 178 | 179 | context "when sites are added and removed" do 180 | it "adds and removes connection specs" do 181 | default_spec = conn.connection_spec(db: "default") 182 | second_spec = conn.connection_spec(db: "second") 183 | 184 | conn.instance.instance_variable_set(:@config_filename, fixture_path("three_dbs.yml")) 185 | conn.reload 186 | 187 | expect(default_spec).to eq(conn.connection_spec(db: "default")) 188 | expect(second_spec).to eq(conn.connection_spec(db: "second")) 189 | expect(conn.connection_spec(db: "third")).not_to eq(nil) 190 | 191 | conn.instance.instance_variable_set(:@config_filename, fixture_path("two_dbs.yml")) 192 | conn.reload 193 | 194 | expect(default_spec).to eq(conn.connection_spec(db: "default")) 195 | expect(second_spec).to eq(conn.connection_spec(db: "second")) 196 | expect(conn.connection_spec(db: "third")).to eq(nil) 197 | end 198 | end 199 | end 200 | end 201 | 202 | describe '.current_hostname' do 203 | before do 204 | conn.config_filename = fixture_path("two_dbs.yml") 205 | end 206 | 207 | it 'should return the right hostname' do 208 | with_connection('default') do 209 | expect(conn.current_hostname).to eq('default.localhost') 210 | end 211 | 212 | with_connection('second') do 213 | expect(conn.current_hostname).to eq('second.localhost') 214 | 215 | conn.config_filename = fixture_path("two_dbs_updated.yml") 216 | 217 | expect(conn.current_hostname).to eq('seconded.localhost') 218 | end 219 | end 220 | end 221 | 222 | describe '.current_db_hostnames' do 223 | before do 224 | conn.config_filename = fixture_path("two_dbs.yml") 225 | end 226 | 227 | it 'should return the right hostname' do 228 | with_connection('default') do 229 | expect(conn.current_db_hostnames).to contain_exactly('default.localhost') 230 | end 231 | 232 | with_connection('second') do 233 | expect(conn.current_db_hostnames).to contain_exactly('2nd.localhost', 'second.localhost') 234 | end 235 | end 236 | end 237 | 238 | describe '.default_connection_handler=' do 239 | before do 240 | conn.config_filename = fixture_path("two_dbs.yml") 241 | end 242 | 243 | it 'should raise the right error if attempting to assign an invalid argument' do 244 | expect do 245 | conn.default_connection_handler = 'test' 246 | end.to raise_error(ArgumentError) 247 | end 248 | 249 | it 'should allow the default connection handler to be assigned' do 250 | default_handler = ActiveRecord::Base.connection_handler 251 | new_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new 252 | 253 | conn.default_connection_handler = new_handler 254 | conn.establish_connection(db: described_class::DEFAULT) 255 | 256 | expect(ActiveRecord::Base.connection_handler).to eq(new_handler) 257 | 258 | conn.default_connection_handler = default_handler 259 | conn.establish_connection(db: described_class::DEFAULT) 260 | 261 | expect(ActiveRecord::Base.connection_handler).to eq(default_handler) 262 | end 263 | end 264 | 265 | end 266 | -------------------------------------------------------------------------------- /spec/fixtures/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: sqlite3 3 | database: "tmp/db.test" 4 | timeout: 5000 5 | host_names: 6 | - default.localhost 7 | -------------------------------------------------------------------------------- /spec/fixtures/database_without_prepared_statements.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: sqlite3 3 | database: "tmp/db.test" 4 | timeout: 5000 5 | host_names: 6 | - default.localhost 7 | prepared_statements: false 8 | -------------------------------------------------------------------------------- /spec/fixtures/three_dbs.yml: -------------------------------------------------------------------------------- 1 | second: 2 | adapter: sqlite3 3 | database: "tmp/db1.test" 4 | host_names: 5 | - second.localhost 6 | - 2nd.localhost 7 | third: 8 | adapter: sqlite3 9 | database: "tmp/db2.test" 10 | host_names: 11 | - third.localhost 12 | - 3rd.localhost 13 | -------------------------------------------------------------------------------- /spec/fixtures/two_dbs.yml: -------------------------------------------------------------------------------- 1 | second: 2 | adapter: sqlite3 3 | database: "tmp/db1.test" 4 | host_names: 5 | - second.localhost 6 | - 2nd.localhost 7 | -------------------------------------------------------------------------------- /spec/fixtures/two_dbs_updated.yml: -------------------------------------------------------------------------------- 1 | second: 2 | adapter: sqlite3 3 | database: "tmp/db1.test" 4 | host_names: 5 | - seconded.localhost 6 | - 2nd.localhost 7 | -------------------------------------------------------------------------------- /spec/middleware_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'spec_helper' 3 | require 'rails_multisite' 4 | require 'rack/test' 5 | require 'json' 6 | 7 | describe RailsMultisite::Middleware do 8 | include Rack::Test::Methods 9 | 10 | let :config do 11 | {} 12 | end 13 | 14 | def app(config = {}) 15 | 16 | RailsMultisite::ConnectionManagement.config_filename = 'spec/fixtures/two_dbs.yml' 17 | 18 | @app ||= Rack::Builder.new { 19 | use RailsMultisite::Middleware, config 20 | map '/html' do 21 | run(proc do |env| 22 | request = Rack::Request.new(env) 23 | [200, { 'Content-Type' => 'text/html' }, "

#{request.hostname}

\n \t"] 24 | end) 25 | end 26 | map '/salts' do 27 | run(proc do |env| 28 | [200, { 'Content-Type' => 'application/json' }, env.slice(*RailsMultisite::CookieSalt::COOKIE_SALT_KEYS).to_json] 29 | end) 30 | end 31 | }.to_app 32 | end 33 | 34 | after do 35 | RailsMultisite::ConnectionManagement.clear_settings! 36 | end 37 | 38 | describe '__ws lookup support' do 39 | it 'returns 200 for valid site' do 40 | 41 | RailsMultisite::ConnectionManagement.asset_hostnames = ["b.com", "default.localhost"] 42 | 43 | get 'http://second.localhost/html?__ws=default.localhost' 44 | expect(last_response).to be_ok 45 | expect(last_response.body).to include("second.localhost") 46 | expect(last_response.body).to_not include("default.localhost") 47 | 48 | get 'http://default.localhost/html?__ws=second.localhost' 49 | expect(last_response).to be_ok 50 | expect(last_response.body).to include("default.localhost") 51 | expect(last_response.body).to_not include("second.localhost") 52 | 53 | RailsMultisite::ConnectionManagement.asset_hostnames = nil 54 | 55 | get 'http://second.localhost/html?__ws=default.localhost' 56 | expect(last_response).to be_ok 57 | expect(last_response.body).to include("second.localhost") 58 | expect(last_response.body).to_not include("default.localhost") 59 | end 60 | 61 | end 62 | 63 | describe 'can whitelist a 404 to go to default site' do 64 | 65 | let :session do 66 | config = { 67 | db_lookup: lambda do |env| 68 | if env["rack.request.query_string"] == "allow" 69 | "default" 70 | else 71 | nil 72 | end 73 | end 74 | } 75 | mock_session = Rack::MockSession.new(app(config)) 76 | Rack::Test::Session.new(mock_session) 77 | end 78 | 79 | it 'returns 404 for disallowed path' do 80 | session.get 'http://boom.com/html' 81 | expect(session.last_response).to be_not_found 82 | end 83 | 84 | it 'returns 200 for invalid sites' do 85 | session.get 'http://boom.com/html?allow' 86 | expect(session.last_response).to be_ok 87 | end 88 | end 89 | 90 | describe 'with a valid request' do 91 | 92 | it 'returns 200 for valid site' do 93 | get 'http://second.localhost/html' 94 | expect(last_response).to be_ok 95 | end 96 | 97 | it 'returns 200 for valid main site' do 98 | get 'http://default.localhost/html' 99 | expect(last_response).to be_ok 100 | end 101 | 102 | it 'returns 404 for invalid site' do 103 | get '/html' 104 | expect(last_response).to be_not_found 105 | end 106 | end 107 | 108 | describe 'encrypted/signed cookie salts' do 109 | it 'updates salts per-hostname' do 110 | get 'http://default.localhost/salts' 111 | expect(last_response).to be_ok 112 | default_salts = JSON.parse(last_response.body) 113 | expect(default_salts.keys).to contain_exactly(*RailsMultisite::CookieSalt::COOKIE_SALT_KEYS) 114 | expect(default_salts.values).to all(include("default.localhost")) 115 | 116 | get 'http://second.localhost/salts' 117 | expect(last_response).to be_ok 118 | second_salts = JSON.parse(last_response.body) 119 | expect(second_salts.keys).to contain_exactly(*RailsMultisite::CookieSalt::COOKIE_SALT_KEYS) 120 | expect(second_salts.values).to all(include("second.localhost")) 121 | 122 | leaked_previous_hostname = second_salts.values.any? { |v| v.include?("default.localhost") } 123 | expect(leaked_previous_hostname).to eq(false) 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | ENV["RAILS_ENV"] ||= 'test' 3 | RSpec.configure do |config| 4 | config.order = 'random' 5 | 6 | require 'sqlite3' 7 | require 'byebug' 8 | require 'active_record' 9 | require 'active_record/base' 10 | 11 | class SQLite3::Database 12 | def self.query_log 13 | @@query_log ||= [] 14 | end 15 | 16 | alias_method :old_execute, :execute 17 | alias_method :old_prepare, :prepare 18 | 19 | def execute(*args, &blk) 20 | self.class.query_log << [args, caller, Thread.current.object_id] 21 | old_execute(*args, &blk) 22 | end 23 | 24 | def prepare(*args, &blk) 25 | self.class.query_log << [args, caller, Thread.current.object_id] 26 | old_prepare(*args, &blk) 27 | end 28 | end 29 | 30 | def fixture_path(name) 31 | File.expand_path("fixtures/#{name}", File.dirname(__FILE__)) 32 | end 33 | 34 | def load_db_config(name) 35 | if defined?(ActiveRecord::DatabaseConfigurations) 36 | configs = ActiveRecord::DatabaseConfigurations.new(YAML.safe_load(File.open(fixture_path(name)))) 37 | ActiveRecord::Base.configurations = configs 38 | else 39 | ActiveRecord::Base.configurations = YAML.safe_load(File.open(fixture_path(name))) 40 | end 41 | end 42 | 43 | config.before(:suite) do 44 | load_db_config("database.yml") 45 | ActiveRecord.legacy_connection_handling = false if ActiveRecord.respond_to?(:legacy_connection_handling) 46 | end 47 | end 48 | --------------------------------------------------------------------------------