├── test ├── statics │ ├── test │ ├── index.htm │ ├── index.html │ ├── test.html │ └── existing.html ├── 404.html ├── Maintenance.html ├── spec_rack_contrib.rb ├── spec_rack_garbagecollector.rb ├── spec_rack_runtime.rb ├── mail_settings.rb ├── spec_rack_config.rb ├── spec_rack_bounce_favicon.rb ├── spec_rack_lighttpd_script_name_fix.rb ├── spec_rack_evil.rb ├── spec_rack_proctitle.rb ├── spec_rack_backstage.rb ├── spec_rack_callbacks.rb ├── spec_rack_response_headers.rb ├── spec_rack_try_static.rb ├── spec_rack_host_meta.rb ├── spec_rack_nested_params.rb ├── spec_rack_not_found.rb ├── spec_rack_profiler.rb ├── spec_rack_enforce_valid_encoding.rb ├── spec_rack_cookies.rb ├── spec_rack_post_body_content_type_parser.rb ├── spec_rack_expectation_cascade.rb ├── spec_rack_csshttprequest.rb ├── spec_rack_mailexceptions.rb ├── spec_rack_relative_redirect.rb ├── spec_rack_deflect.rb ├── spec_rack_lazy_conditional_get.rb ├── spec_rack_simple_endpoint.rb ├── spec_rack_locale.rb ├── spec_rack_common_cookies.rb ├── spec_rack_json_body_parser_spec.rb ├── spec_rack_access.rb ├── spec_rack_static_cache.rb ├── spec_rack_response_cache.rb └── spec_rack_jsonp.rb ├── lib └── rack │ ├── contrib │ ├── version.rb │ ├── config.rb │ ├── runtime.rb │ ├── garbagecollector.rb │ ├── evil.rb │ ├── bounce_favicon.rb │ ├── lighttpd_script_name_fix.rb │ ├── backstage.rb │ ├── time_zone.rb │ ├── printout.rb │ ├── enforce_valid_encoding.rb │ ├── response_headers.rb │ ├── not_found.rb │ ├── callbacks.rb │ ├── expectation_cascade.rb │ ├── nested_params.rb │ ├── proctitle.rb │ ├── try_static.rb │ ├── route_exceptions.rb │ ├── cookies.rb │ ├── common_cookies.rb │ ├── csshttprequest.rb │ ├── host_meta.rb │ ├── signals.rb │ ├── relative_redirect.rb │ ├── access.rb │ ├── simple_endpoint.rb │ ├── post_body_content_type_parser.rb │ ├── locale.rb │ ├── json_body_parser.rb │ ├── response_cache.rb │ ├── lazy_conditional_get.rb │ ├── jsonp.rb │ ├── mailexceptions.rb │ ├── profiler.rb │ ├── deflect.rb │ └── static_cache.rb │ └── contrib.rb ├── CHANGELOG.md ├── .gitignore ├── Rakefile ├── Gemfile ├── .github └── workflows │ └── ci.yml ├── AUTHORS ├── COPYING ├── rack-contrib.gemspec ├── CONTRIBUTING.md └── README.md /test/statics/test: -------------------------------------------------------------------------------- 1 | rubyrack -------------------------------------------------------------------------------- /test/404.html: -------------------------------------------------------------------------------- 1 | Custom 404 page content -------------------------------------------------------------------------------- /test/statics/index.htm: -------------------------------------------------------------------------------- 1 | index.htm 2 | -------------------------------------------------------------------------------- /test/statics/index.html: -------------------------------------------------------------------------------- 1 | index.html 2 | -------------------------------------------------------------------------------- /test/Maintenance.html: -------------------------------------------------------------------------------- 1 | Under maintenance. -------------------------------------------------------------------------------- /test/statics/test.html: -------------------------------------------------------------------------------- 1 | extensions rule! 2 | -------------------------------------------------------------------------------- /test/statics/existing.html: -------------------------------------------------------------------------------- 1 | existing.html 2 | -------------------------------------------------------------------------------- /lib/rack/contrib/version.rb: -------------------------------------------------------------------------------- 1 | module Rack 2 | module Contrib 3 | VERSION = '2.5.0' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/rack/contrib/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rack' 4 | require 'rack/config' 5 | -------------------------------------------------------------------------------- /lib/rack/contrib/runtime.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rack' 4 | require 'rack/runtime' 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | The `rack-contrib` changelog can be viewed on the [Releases](https://github.com/rack/rack-contrib/releases) page. 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /pkg 2 | /doc 3 | /RDOX 4 | ChangeLog 5 | /Gemfile.lock 6 | /.bundle 7 | /test/gemfiles/.bundle 8 | /test/gemfiles/*.lock 9 | -------------------------------------------------------------------------------- /test/spec_rack_contrib.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/contrib' 5 | 6 | describe "Rack::Contrib" do 7 | specify "should expose release" do 8 | _(Rack::Contrib).must_respond_to(:release) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/rack/contrib/garbagecollector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | # Forces garbage collection after each request. 5 | class GarbageCollector 6 | def initialize(app) 7 | @app = app 8 | end 9 | 10 | def call(env) 11 | @app.call(env) 12 | ensure 13 | GC.start 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/rack/contrib/evil.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class Evil 5 | # Lets you return a response to the client immediately from anywhere ( M V or C ) in the code. 6 | def initialize(app) 7 | @app = app 8 | end 9 | 10 | def call(env) 11 | catch(:response) { @app.call(env) } 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/rack/contrib/bounce_favicon.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | # Bounce those annoying favicon.ico requests 5 | class BounceFavicon 6 | def initialize(app) 7 | @app = app 8 | end 9 | 10 | def call(env) 11 | if env["PATH_INFO"] == "/favicon.ico" 12 | [404, {"content-type" => "text/html", "content-length" => "0"}, []] 13 | else 14 | @app.call(env) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/spec_rack_garbagecollector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/garbagecollector' 6 | 7 | describe 'Rack::GarbageCollector' do 8 | 9 | specify 'starts the garbage collector after each request' do 10 | app = lambda { |env| 11 | [200, {'Content-Type'=>'text/plain'}, ['Hello World']] } 12 | Rack::Lint.new(Rack::GarbageCollector.new(app).call({})) 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /lib/rack/contrib/lighttpd_script_name_fix.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | # Lighttpd sets the wrong SCRIPT_NAME and PATH_INFO if you mount your 5 | # FastCGI app at "/". This middleware fixes this issue. 6 | 7 | class LighttpdScriptNameFix 8 | def initialize(app) 9 | @app = app 10 | end 11 | 12 | def call(env) 13 | env["PATH_INFO"] = env["SCRIPT_NAME"].to_s + env["PATH_INFO"].to_s 14 | env["SCRIPT_NAME"] = "" 15 | @app.call(env) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/spec_rack_runtime.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/contrib/runtime' 5 | 6 | describe "Rack::Runtime" do 7 | specify "exists and sets X-Runtime header" do 8 | app = lambda { |env| [200, {'Content-Type' => 'text/plain'}, "Hello, World!"] } 9 | response = Rack::Runtime.new(app).call({}) 10 | if Rack.release < "3" 11 | _(response[1]['X-Runtime']).must_match /[\d\.]+/ 12 | else 13 | _(response[1]['x-runtime']).must_match /[\d\.]+/ 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/rack/contrib/backstage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class Backstage 5 | File = ::File 6 | 7 | def initialize(app, path) 8 | @app = app 9 | @file = File.expand_path(path) 10 | end 11 | 12 | def call(env) 13 | if File.exist?(@file) 14 | content = File.read(@file) 15 | length = content.bytesize.to_s 16 | [503, {'content-type' => 'text/html', 'content-length' => length}, [content]] 17 | else 18 | @app.call(env) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/mail_settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | TEST_SMTP = nil 4 | 5 | # Enable SMTP tests by providing the following for your SMTP server. 6 | # 7 | # TEST_SMTP = { 8 | # :server => 'localhost', 9 | # :domain => 'localhost', 10 | # :port => 25, 11 | # :authentication => :login, 12 | # :user_name => nil, 13 | # :password => nil 14 | # } 15 | #TEST_SMTP_TLS = { 16 | # :server => 'smtp.gmail.com', 17 | # :domain => 'gmail.com', 18 | # :port => 587, 19 | # :authentication => 'plain', 20 | # :user_name => nil, 21 | # :password => nil, 22 | #} 23 | -------------------------------------------------------------------------------- /test/spec_rack_config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/config' 6 | 7 | describe "Rack::Config" do 8 | 9 | specify "should accept a block that modifies the environment" do 10 | app = Rack::Builder.new do 11 | use Rack::Lint 12 | use Rack::ContentLength 13 | use Rack::Config do |env| 14 | env['greeting'] = 'hello' 15 | end 16 | run lambda { |env| 17 | [200, {'content-type' => 'text/plain'}, [env['greeting'] || '']] 18 | } 19 | end 20 | response = Rack::MockRequest.new(app).get('/') 21 | _(response.body).must_equal('hello') 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rdoc/task' 4 | require 'rake/testtask' 5 | 6 | desc "Run all the tests" 7 | task :default => [:test] 8 | 9 | desc "Run specs" 10 | Rake::TestTask.new do |t| 11 | t.libs << "test" 12 | t.test_files = FileList['test/spec_*.rb'] 13 | end 14 | 15 | desc "Generate RDoc documentation" 16 | RDoc::Task.new(:rdoc) do |rdoc| 17 | rdoc.options << '--line-numbers' << '--inline-source' << 18 | '--main' << 'README' << 19 | '--title' << 'Rack Contrib Documentation' << 20 | '--charset' << 'utf-8' 21 | rdoc.rdoc_dir = "doc" 22 | rdoc.rdoc_files.include 'README.rdoc' 23 | rdoc.rdoc_files.include('lib/rack/*.rb') 24 | rdoc.rdoc_files.include('lib/rack/*/*.rb') 25 | end 26 | -------------------------------------------------------------------------------- /test/spec_rack_bounce_favicon.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack' 5 | require 'rack/contrib/bounce_favicon' 6 | 7 | describe Rack::BounceFavicon do 8 | app = Rack::Lint.new( 9 | Rack::Builder.new do 10 | use Rack::BounceFavicon 11 | run lambda { |env| [200, {}, []] } 12 | end 13 | ) 14 | 15 | specify 'does nothing when requesting paths other than the favicon' do 16 | response = Rack::MockRequest.new(app).get('/') 17 | _(response.status).must_equal(200) 18 | end 19 | 20 | specify 'gives a 404 when requesting the favicon' do 21 | response = Rack::MockRequest.new(app).get('/favicon.ico') 22 | _(response.status).must_equal(404) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'i18n', '~> 0.6', '>= 0.6.8' 8 | gem 'json', '~> 2.0' 9 | gem 'mime-types', '~> 3.0' 10 | gem 'minitest', '~> 5.6' 11 | gem 'minitest-hooks', '~> 1.0' 12 | gem 'mail', '~> 2.3', '>= 2.6.4' 13 | gem 'nbio-csshttprequest', '~> 1.0' 14 | gem 'rack', ENV['RACK_VERSION'] 15 | gem 'rake' 16 | gem 'rdoc', '~> 5.0' 17 | gem 'ruby-prof' 18 | gem 'timecop', '~> 0.9' 19 | 20 | # See https://github.com/ruby/cgi/pull/29 21 | # Needed to have passing tests on Ruby 2.7, Ruby 3.0 22 | gem 'cgi', '>= 0.3.6' if RUBY_VERSION >= '2.7.0' && RUBY_VERSION <= '3.1.0' 23 | 24 | group :maintenance, optional: true do 25 | gem "bake" 26 | gem "bake-gem" 27 | end 28 | -------------------------------------------------------------------------------- /lib/rack/contrib/time_zone.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class TimeZone 5 | Javascript = <<-EOJ 6 | function setTimezoneCookie() { 7 | var offset = (new Date()).getTimezoneOffset() 8 | var date = new Date(); 9 | date.setTime(date.getTime()+3600000); 10 | document.cookie = "utc_offset="+offset+"; expires="+date.toGMTString();+"; path=/"; 11 | } 12 | EOJ 13 | 14 | def initialize(app) 15 | @app = app 16 | end 17 | 18 | def call(env) 19 | request = Rack::Request.new(env) 20 | if utc_offset = request.cookies["utc_offset"] 21 | env["rack.timezone.utc_offset"] = -(utc_offset.to_i * 60) 22 | end 23 | 24 | @app.call(env) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/rack/contrib/printout.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | #prints the environment and request for simple debugging 5 | class Printout 6 | def initialize(app) 7 | @app = app 8 | end 9 | 10 | def call(env) 11 | # See https://github.com/rack/rack/blob/main/SPEC.rdoc for details 12 | puts "**********\n Environment\n **************" 13 | puts env.inspect 14 | 15 | puts "**********\n Response\n **************" 16 | response = @app.call(env) 17 | puts response.inspect 18 | 19 | puts "**********\n Response contents\n **************" 20 | response[2].each do |chunk| 21 | puts chunk 22 | end 23 | puts "\n \n" 24 | return response 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/spec_rack_lighttpd_script_name_fix.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/lighttpd_script_name_fix' 6 | 7 | describe "Rack::LighttpdScriptNameFix" do 8 | specify "corrects SCRIPT_NAME and PATH_INFO set by lighttpd " do 9 | env = Rack::MockRequest.env_for( 10 | '', 11 | { 12 | "PATH_INFO" => "/foo/bar/baz", 13 | "SCRIPT_NAME" => "/hello" 14 | } 15 | ) 16 | app = lambda { |_| [200, {'content-type' => 'text/plain'}, ["Hello, World!"]] } 17 | response = Rack::Lint.new(Rack::LighttpdScriptNameFix.new(app)).call(env) 18 | _(env['SCRIPT_NAME'].empty?).must_equal(true) 19 | _(env['PATH_INFO']).must_equal '/hello/foo/bar/baz' 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rack/contrib/enforce_valid_encoding.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | # Ensure that the path and query string presented to the application 5 | # contains only valid characters. If the validation fails, then a 6 | # 400 Bad Request response is returned immediately. 7 | # 8 | # Requires: Ruby 1.9 9 | # 10 | class EnforceValidEncoding 11 | def initialize app 12 | @app = app 13 | end 14 | 15 | def call env 16 | full_path = (env.fetch('PATH_INFO', '') + env.fetch('QUERY_STRING', '')) 17 | if full_path.force_encoding("US-ASCII").valid_encoding? && 18 | Rack::Utils.unescape(full_path).valid_encoding? 19 | @app.call env 20 | else 21 | [400, {'content-type'=>'text/plain'}, ['Bad Request']] 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/spec_rack_evil.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/evil' 6 | require 'erb' 7 | 8 | describe "Rack::Evil" do 9 | app = lambda do |env| 10 | template = ERB.new("<%= throw :response, [404, {'content-type' => 'text/html'}, ['Never know where it comes from']] %>") 11 | [200, {'content-type' => 'text/plain'}, template.result(binding)] 12 | end 13 | 14 | env = Rack::MockRequest.env_for('', {}) 15 | 16 | specify "should enable the app to return the response from anywhere" do 17 | status, headers, body = Rack::Lint.new(Rack::Evil.new(app)).call(env) 18 | 19 | _(status).must_equal 404 20 | _(headers['content-type']).must_equal 'text/html' 21 | _(body.to_enum.to_a).must_equal ['Never know where it comes from'] 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/rack/contrib/response_headers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | # Allows you to tap into the response headers. Yields a Rack::Utils::HeaderHash 5 | # (Rack 2) or a Rack::Headers (Rack 3) of current response headers to the block. 6 | # Example: 7 | # 8 | # use Rack::ResponseHeaders do |headers| 9 | # headers['X-Foo'] = 'bar' 10 | # headers.delete('X-Baz') 11 | # end 12 | # 13 | class ResponseHeaders 14 | HEADERS_KLASS = Rack.release < "3" ? Utils::HeaderHash : Headers 15 | private_constant :HEADERS_KLASS 16 | 17 | def initialize(app, &block) 18 | @app = app 19 | @block = block 20 | end 21 | 22 | def call(env) 23 | response = @app.call(env) 24 | headers = HEADERS_KLASS.new.merge(response[1]) 25 | @block.call(headers) 26 | response[1] = headers 27 | response 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/rack/contrib/not_found.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | # Rack::NotFound is a default endpoint. Optionally initialize with the 5 | # path to a custom 404 page, to override the standard response body. 6 | # 7 | # Examples: 8 | # 9 | # Serve default 404 response: 10 | # run Rack::NotFound.new 11 | # 12 | # Serve a custom 404 page: 13 | # run Rack::NotFound.new('path/to/your/404.html') 14 | 15 | class NotFound 16 | F = ::File 17 | 18 | def initialize(path = nil, content_type = 'text/html') 19 | if path.nil? 20 | @content = "Not found\n" 21 | else 22 | @content = F.read(path) 23 | end 24 | @length = @content.bytesize.to_s 25 | 26 | @content_type = content_type 27 | end 28 | 29 | def call(env) 30 | [404, {'content-type' => @content_type, 'content-length' => @length}, [@content]] 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/rack/contrib/callbacks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class Callbacks 5 | def initialize(&block) 6 | @before = [] 7 | @after = [] 8 | instance_eval(&block) if block_given? 9 | end 10 | 11 | def before(middleware, *args, &block) 12 | if block_given? 13 | @before << middleware.new(*args, &block) 14 | else 15 | @before << middleware.new(*args) 16 | end 17 | end 18 | 19 | def after(middleware, *args, &block) 20 | if block_given? 21 | @after << middleware.new(*args, &block) 22 | else 23 | @after << middleware.new(*args) 24 | end 25 | end 26 | 27 | def run(app) 28 | @app = app 29 | end 30 | 31 | def call(env) 32 | @before.each {|c| c.call(env) } 33 | 34 | response = @app.call(env) 35 | 36 | @after.inject(response) {|r, c| c.call(r) } 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/spec_rack_proctitle.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/proctitle' 6 | 7 | describe "Rack::ProcTitle" do 8 | progname = ::File.basename($0) 9 | appname = ::File.expand_path(__FILE__).split('/')[-3] 10 | 11 | def proc_title(app) 12 | Rack::Lint.new(Rack::ProcTitle.new(app)) 13 | end 14 | 15 | def simple_app(body=['Hello World!']) 16 | lambda { |env| [200, {'content-type' => 'text/plain'}, body] } 17 | end 18 | 19 | specify "should set the process title when created" do 20 | proc_title(simple_app) 21 | _($0).must_equal "#{progname} [#{appname}] init ..." 22 | end 23 | 24 | specify "should set the process title on each request" do 25 | app = proc_title(simple_app) 26 | req = Rack::MockRequest.new(app) 27 | 10.times { req.get('/hello') } 28 | _($0).must_equal "#{progname} [#{appname}/80] (10) GET /hello" 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | name: Ruby ${{ matrix.ruby }} Rack ${{ matrix.rack }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | os: [ ubuntu-20.04 ] 15 | rack: [ '~> 2.0', '~> 3.0' ] 16 | ruby: [ 2.3, 2.4, 2.5, 2.6, 2.7, '3.0', 3.1, 3.2, 3.3 ] 17 | gemfile: [ Gemfile ] 18 | exclude: 19 | # Rack 3 needs >= Ruby 2.4 20 | - { ruby: 2.2, rack: '~> 3.0' } 21 | - { ruby: 2.3, rack: '~> 3.0' } 22 | runs-on: ${{ matrix.os }} 23 | env: 24 | RACK_VERSION: ${{ matrix.rack }} 25 | BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - uses: ruby/setup-ruby@v1 30 | with: 31 | ruby-version: ${{ matrix.ruby }} 32 | bundler-cache: true 33 | 34 | - run: bundle exec rake 35 | -------------------------------------------------------------------------------- /lib/rack/contrib/expectation_cascade.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class ExpectationCascade 5 | Expect = "HTTP_EXPECT".freeze 6 | ContinueExpectation = "100-continue".freeze 7 | 8 | ExpectationFailed = [417, {"content-type" => "text/html"}, []] 9 | NotFound = [404, {"content-type" => "text/html"}, []] 10 | 11 | attr_reader :apps 12 | 13 | def initialize 14 | @apps = [] 15 | yield self if block_given? 16 | end 17 | 18 | def call(env) 19 | set_expectation = env[Expect] != ContinueExpectation 20 | env[Expect] = ContinueExpectation if set_expectation 21 | @apps.each do |app| 22 | result = app.call(env) 23 | return result unless result[0].to_i == 417 24 | end 25 | set_expectation ? NotFound : ExpectationFailed 26 | ensure 27 | env.delete(Expect) if set_expectation 28 | end 29 | 30 | def <<(app) 31 | @apps << app 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/rack/contrib/nested_params.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rack/utils' 4 | 5 | module Rack 6 | # Rack middleware for parsing POST/PUT body data into nested parameters 7 | class NestedParams 8 | 9 | CONTENT_TYPE = 'CONTENT_TYPE'.freeze 10 | POST_BODY = 'rack.input'.freeze 11 | FORM_INPUT = 'rack.request.form_input'.freeze 12 | FORM_HASH = 'rack.request.form_hash'.freeze 13 | FORM_VARS = 'rack.request.form_vars'.freeze 14 | 15 | # supported content type 16 | URL_ENCODED = 'application/x-www-form-urlencoded'.freeze 17 | 18 | def initialize(app) 19 | @app = app 20 | end 21 | 22 | def call(env) 23 | if form_vars = env[FORM_VARS] 24 | Rack::Utils.parse_nested_query(form_vars) 25 | elsif env[CONTENT_TYPE] == URL_ENCODED 26 | post_body = env[POST_BODY] 27 | env[FORM_INPUT] = post_body 28 | env[FORM_HASH] = Rack::Utils.parse_nested_query(post_body.read) 29 | post_body.rewind 30 | end 31 | @app.call(env) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Ryan Tomayko 2 | Joshua Peek 3 | Jeremy Kemper 4 | mynyml 5 | Cameron Walters 6 | Jon Crosby 7 | Matt Todd 8 | Pirmin Kalberer 9 | Rune Botten 10 | Pratik Naik 11 | Paul Sadauskas 12 | Jeremy Evans 13 | Michael Fellinger 14 | Geoff Buesing 15 | Nicolas Mérouze 16 | Cyril Rohr 17 | Harry Vangberg 18 | James Rosen 19 | Mislav Marohnić 20 | Ben Brinckerhoff 21 | Rafael Souza 22 | Stephen Delano 23 | TJ Holowaychuk 24 | anupom syam 25 | ichverstehe 26 | kubicek 27 | Jordi Polo 28 | -------------------------------------------------------------------------------- /lib/rack/contrib/proctitle.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | # Middleware to update the process title ($0) with information about the 5 | # current request. Based loosely on: 6 | # - http://purefiction.net/mongrel_proctitle/ 7 | # - http://github.com/grempe/thin-proctitle/tree/master 8 | # 9 | # NOTE: This will not work properly in a multi-threaded environment. 10 | class ProcTitle 11 | F = ::File 12 | PROGNAME = F.basename($0) 13 | 14 | def initialize(app) 15 | @app = app 16 | @appname = Dir.pwd.split('/').reverse. 17 | find { |name| name !~ /^(\d+|current|releases)$/ } || PROGNAME 18 | @requests = 0 19 | $0 = "#{PROGNAME} [#{@appname}] init ..." 20 | end 21 | 22 | def call(env) 23 | host, port = env['SERVER_NAME'], env['SERVER_PORT'] 24 | meth, path = env['REQUEST_METHOD'], env['PATH_INFO'] 25 | @requests += 1 26 | $0 = "#{PROGNAME} [#{@appname}/#{port}] (#{@requests}) " \ 27 | "#{meth} #{path}" 28 | 29 | @app.call(env) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2008 The Committers 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to 6 | deal in the Software without restriction, including without limitation the 7 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 8 | sell copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 17 | THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 18 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 19 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /test/spec_rack_backstage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/builder' 5 | require 'rack/mock' 6 | require 'rack/contrib/backstage' 7 | 8 | describe "Rack::Backstage" do 9 | specify "shows maintenances page if present" do 10 | app = Rack::Lint.new( 11 | Rack::Builder.new do 12 | use Rack::Backstage, 'test/Maintenance.html' 13 | run lambda { |env| [200, {'content-type' => 'text/plain'}, ["Hello, World!"]] } 14 | end 15 | ) 16 | response = Rack::MockRequest.new(app).get('/') 17 | _(response.body).must_equal('Under maintenance.') 18 | _(response.status).must_equal(503) 19 | end 20 | 21 | specify "passes on request if page is not present" do 22 | app = Rack::Lint.new( 23 | Rack::Builder.new do 24 | use Rack::Backstage, 'test/Nonsense.html' 25 | run lambda { |env| [200, {'content-type' => 'text/plain'}, ["Hello, World!"]] } 26 | end 27 | ) 28 | response = Rack::MockRequest.new(app).get('/') 29 | _(response.body).must_equal('Hello, World!') 30 | _(response.status).must_equal(200) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/rack/contrib/try_static.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | 5 | # The Rack::TryStatic middleware delegates requests to Rack::Static middleware 6 | # trying to match a static file 7 | # 8 | # Examples 9 | # 10 | # use Rack::TryStatic, 11 | # :root => "public", # static files root dir 12 | # :urls => %w[/], # match all requests 13 | # :try => ['.html', 'index.html', '/index.html'] # try these postfixes sequentially 14 | # 15 | # uses same options as Rack::Static with extra :try option which is an array 16 | # of postfixes to find desired file 17 | 18 | class TryStatic 19 | 20 | def initialize(app, options) 21 | @app = app 22 | @try = ['', *options[:try]] 23 | @static = ::Rack::Static.new( 24 | lambda { |_| [404, {}, []] }, 25 | options) 26 | end 27 | 28 | def call(env) 29 | orig_path = env['PATH_INFO'] 30 | found = nil 31 | @try.each do |path| 32 | resp = @static.call(env.merge!({'PATH_INFO' => orig_path + path})) 33 | break if !(403..405).include?(resp[0]) && found = resp 34 | end 35 | found or @app.call(env.merge!('PATH_INFO' => orig_path)) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /rack-contrib.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/rack/contrib/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.specification_version = 2 if s.respond_to? :specification_version= 7 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 8 | 9 | s.name = 'rack-contrib' 10 | s.version = Rack::Contrib::VERSION 11 | 12 | s.licenses = ['MIT'] 13 | 14 | s.description = "Contributed Rack Middleware and Utilities" 15 | s.summary = "Contributed Rack Middleware and Utilities" 16 | 17 | s.authors = ["rack-devel"] 18 | s.email = "rack-devel@googlegroups.com" 19 | 20 | # = MANIFEST = 21 | s.files = %w[ 22 | AUTHORS 23 | COPYING 24 | README.md 25 | ] + `git ls-files -z lib`.split("\0") 26 | 27 | s.test_files = s.files.select {|path| path =~ /^test\/spec_.*\.rb/} 28 | 29 | s.extra_rdoc_files = %w[README.md COPYING] 30 | 31 | s.required_ruby_version = '>= 2.2.2' 32 | 33 | s.add_runtime_dependency 'rack', '< 4' 34 | 35 | s.homepage = "https://github.com/rack/rack-contrib/" 36 | s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "rack-contrib", "--main", "README"] 37 | s.require_paths = %w[lib] 38 | s.rubygems_version = '1.1.1' 39 | end 40 | -------------------------------------------------------------------------------- /lib/rack/contrib/route_exceptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class RouteExceptions 5 | ROUTES = [ 6 | [Exception, '/error/internal'] 7 | ] 8 | 9 | PATH_INFO = 'rack.route_exceptions.path_info'.freeze 10 | EXCEPTION = 'rack.route_exceptions.exception'.freeze 11 | RETURNED = 'rack.route_exceptions.returned'.freeze 12 | 13 | class << self 14 | def route(exception, to) 15 | ROUTES.delete_if{|k,v| k == exception } 16 | ROUTES << [exception, to] 17 | end 18 | 19 | alias []= route 20 | end 21 | 22 | def initialize(app) 23 | @app = app 24 | end 25 | 26 | def call(env, try_again = true) 27 | returned = @app.call(env) 28 | rescue Exception => exception 29 | raise(exception) unless try_again 30 | 31 | ROUTES.each do |klass, to| 32 | next unless klass === exception 33 | return route(to, env, returned, exception) 34 | end 35 | 36 | raise(exception) 37 | end 38 | 39 | def route(to, env, returned, exception) 40 | env.merge!( 41 | PATH_INFO => env['PATH_INFO'], 42 | EXCEPTION => exception, 43 | RETURNED => returned 44 | ) 45 | 46 | env['PATH_INFO'] = to 47 | 48 | call(env, try_again = false) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/rack/contrib/cookies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | class Cookies 5 | class CookieJar < Hash 6 | def initialize(cookies) 7 | @set_cookies = {} 8 | @delete_cookies = {} 9 | super() 10 | update(cookies) 11 | end 12 | 13 | def [](name) 14 | super(name.to_s) 15 | end 16 | 17 | def []=(key, options) 18 | unless options.is_a?(Hash) 19 | options = { :value => options } 20 | end 21 | 22 | options[:path] ||= '/' 23 | @set_cookies[key] = options 24 | super(key.to_s, options[:value]) 25 | end 26 | 27 | def delete(key, options = {}) 28 | options[:path] ||= '/' 29 | @delete_cookies[key] = options 30 | super(key.to_s) 31 | end 32 | 33 | def finish!(resp) 34 | @set_cookies.each { |k, v| resp.set_cookie(k, v) } 35 | @delete_cookies.each { |k, v| resp.delete_cookie(k, v) } 36 | end 37 | end 38 | 39 | def initialize(app) 40 | @app = app 41 | end 42 | 43 | def call(env) 44 | req = Request.new(env) 45 | env['rack.cookies'] = cookies = CookieJar.new(req.cookies) 46 | status, headers, body = @app.call(env) 47 | resp = Response.new(body, status, headers) 48 | cookies.finish!(resp) 49 | resp.to_a 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/rack/contrib/common_cookies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | # Rack middleware to use common cookies across domain and subdomains. 5 | class CommonCookies 6 | DOMAIN_REGEXP = /([^.]*)\.([^.]*|..\...|...\...|..\....)$/ 7 | LOCALHOST_OR_IP_REGEXP = /^([\d.]+|localhost)$/ 8 | PORT = /:\d+$/ 9 | 10 | HEADERS_KLASS = Rack.release < "3" ? Utils::HeaderHash : Headers 11 | private_constant :HEADERS_KLASS 12 | 13 | def initialize(app) 14 | @app = app 15 | end 16 | 17 | def call(env) 18 | status, headers, body = @app.call(env) 19 | headers = HEADERS_KLASS.new.merge(headers) 20 | 21 | host = env['HTTP_HOST'].sub PORT, '' 22 | share_cookie(headers, host) 23 | 24 | [status, headers, body] 25 | end 26 | 27 | private 28 | 29 | def domain(host) 30 | host =~ DOMAIN_REGEXP 31 | ".#{$1}.#{$2}" 32 | end 33 | 34 | def share_cookie(headers, host) 35 | headers['Set-Cookie'] &&= common_cookie(headers, host) if host !~ LOCALHOST_OR_IP_REGEXP 36 | end 37 | 38 | def cookie(headers) 39 | cookies = headers['Set-Cookie'] 40 | cookies.is_a?(Array) ? cookies.join("\n") : cookies 41 | end 42 | 43 | def common_cookie(headers, host) 44 | cookie(headers).gsub(/; domain=[^;]*/, '').gsub(/$/, "; domain=#{domain(host)}") 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/rack/contrib/csshttprequest.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'csshttprequest' 4 | 5 | module Rack 6 | 7 | # A Rack middleware for providing CSSHTTPRequest responses. 8 | class CSSHTTPRequest 9 | HEADERS_KLASS = Rack.release < "3" ? Utils::HeaderHash : Headers 10 | private_constant :HEADERS_KLASS 11 | 12 | def initialize(app) 13 | @app = app 14 | end 15 | 16 | # Proxies the request to the application then encodes the response with 17 | # the CSSHTTPRequest encoder 18 | def call(env) 19 | status, headers, response = @app.call(env) 20 | headers = HEADERS_KLASS.new.merge(headers) 21 | 22 | if chr_request?(env) 23 | encoded_response = encode(response) 24 | modify_headers!(headers, encoded_response) 25 | response = [encoded_response] 26 | end 27 | [status, headers, response] 28 | end 29 | 30 | def chr_request?(env) 31 | env['csshttprequest.chr'] ||= 32 | !(/\.chr$/.match(env['PATH_INFO'])).nil? || Rack::Request.new(env).params['_format'] == 'chr' 33 | end 34 | 35 | def encode(body) 36 | ::CSSHTTPRequest.encode(body.to_enum.to_a.join) 37 | end 38 | 39 | def modify_headers!(headers, encoded_response) 40 | headers['Content-Length'] = encoded_response.bytesize.to_s 41 | headers['Content-Type'] = 'text/css' 42 | nil 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/spec_rack_callbacks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/callbacks' 6 | 7 | class Flame 8 | def call(env) 9 | env['flame'] = 'F Lifo..' 10 | end 11 | end 12 | 13 | class Pacify 14 | def initialize(with) 15 | @with = with 16 | end 17 | 18 | def call(env) 19 | env['peace'] = @with 20 | end 21 | end 22 | 23 | class Finale 24 | def call(response) 25 | status, headers, body = response 26 | 27 | headers['last'] = 'Finale' 28 | $old_status = status 29 | 30 | [201, headers, body] 31 | end 32 | end 33 | 34 | class TheEnd 35 | def call(response) 36 | status, headers, body = response 37 | 38 | headers['last'] = 'TheEnd' 39 | [201, headers, body] 40 | end 41 | end 42 | 43 | describe "Rack::Callbacks" do 44 | specify "works for love and small stack trace" do 45 | callback_app = Rack::Callbacks.new do 46 | before Flame 47 | before Pacify, "with love" 48 | 49 | run lambda {|env| [200, {}, [env['flame'], env['peace']]] } 50 | 51 | after Finale 52 | after TheEnd 53 | end 54 | 55 | app = Rack::Lint.new( 56 | Rack::Builder.new do 57 | run callback_app 58 | end.to_app 59 | ) 60 | 61 | response = Rack::MockRequest.new(app).get("/") 62 | 63 | _(response.body).must_equal 'F Lifo..with love' 64 | 65 | _($old_status).must_equal 200 66 | _(response.status).must_equal 201 67 | 68 | _(response.headers['last']).must_equal 'TheEnd' 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/rack/contrib/host_meta.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | 5 | # Rack middleware implementing the IETF draft: "Host Metadata for the Web" 6 | # including support for Link-Pattern elements as described in the IETF draft: 7 | # "Link-based Resource Descriptor Discovery." 8 | # 9 | # Usage: 10 | # use Rack::HostMeta do 11 | # link :uri => '/robots.txt', :rel => 'robots' 12 | # link :uri => '/w3c/p3p.xml', :rel => 'privacy', :type => 'application/p3p.xml' 13 | # link :pattern => '{uri};json_schema', :rel => 'describedby', :type => 'application/x-schema+json' 14 | # end 15 | # 16 | # See also: 17 | # http://tools.ietf.org/html/draft-nottingham-site-meta 18 | # http://tools.ietf.org/html/draft-hammer-discovery 19 | # 20 | # TODO: 21 | # Accept POST operations allowing downstream services to register themselves 22 | # 23 | class HostMeta 24 | def initialize(app, &block) 25 | @app = app 26 | @lines = [] 27 | instance_eval(&block) 28 | @response = @lines.join("\n") 29 | end 30 | 31 | def call(env) 32 | if env['PATH_INFO'] == '/host-meta' 33 | [200, {'content-type' => 'application/host-meta'}, [@response]] 34 | else 35 | @app.call(env) 36 | end 37 | end 38 | 39 | protected 40 | 41 | def link(config) 42 | line = config[:uri] ? "Link: <#{config[:uri]}>;" : "Link-Pattern: <#{config[:pattern]}>;" 43 | fragments = [] 44 | fragments << "rel=\"#{config[:rel]}\"" if config[:rel] 45 | fragments << "type=\"#{config[:type]}\"" if config[:type] 46 | @lines << "#{line} #{fragments.join("; ")}" 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/rack/contrib/signals.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | # Installs signal handlers that are safely processed after a request 5 | # 6 | # NOTE: This middleware should not be used in a threaded environment 7 | # 8 | # use Rack::Signals.new do 9 | # trap 'INT', lambda { 10 | # puts "Exiting now" 11 | # exit 12 | # } 13 | # 14 | # trap_when_ready 'USR1', lambda { 15 | # puts "Exiting when ready" 16 | # exit 17 | # } 18 | # end 19 | class Signals 20 | class BodyWithCallback 21 | def initialize(body, callback) 22 | @body, @callback = body, callback 23 | end 24 | 25 | def each(&block) 26 | @body.each(&block) 27 | @callback.call 28 | end 29 | 30 | def close 31 | @body.close if @body.respond_to?(:close) 32 | end 33 | end 34 | 35 | def initialize(app, &block) 36 | @app = app 37 | @processing = false 38 | @when_ready = nil 39 | instance_eval(&block) 40 | end 41 | 42 | def call(env) 43 | begin 44 | @processing, @when_ready = true, nil 45 | status, headers, body = @app.call(env) 46 | 47 | if handler = @when_ready 48 | body = BodyWithCallback.new(body, handler) 49 | @when_ready = nil 50 | end 51 | ensure 52 | @processing = false 53 | end 54 | 55 | [status, headers, body] 56 | end 57 | 58 | def trap_when_ready(signal, handler) 59 | when_ready_handler = lambda { |signal| 60 | if @processing 61 | @when_ready = lambda { handler.call(signal) } 62 | else 63 | handler.call(signal) 64 | end 65 | } 66 | trap(signal, when_ready_handler) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/spec_rack_response_headers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack' 5 | require 'rack/contrib/response_headers' 6 | 7 | describe "Rack::ResponseHeaders" do 8 | def response_header(app, &block) 9 | Rack::Lint.new(Rack::ResponseHeaders.new(app, &block)) 10 | end 11 | 12 | def env 13 | Rack::MockRequest.env_for('', {}) 14 | end 15 | 16 | specify "yields a HeaderHash (rack 2) or Headers (rack 3) of response headers" do 17 | orig_headers = {'X-Foo' => 'foo', 'X-Bar' => 'bar'} 18 | app = Proc.new {[200, orig_headers, []]} 19 | headers_klass = Rack.release < "3" ? Rack::Utils::HeaderHash : Rack::Headers 20 | middleware = response_header(app) do |headers| 21 | assert_instance_of headers_klass, headers 22 | if Rack.release < "3" 23 | _(orig_headers).must_equal headers 24 | else 25 | _(orig_headers).must_equal({'X-Foo' => 'foo', 'X-Bar' => 'bar'}) 26 | end 27 | end 28 | middleware.call(env) 29 | end 30 | 31 | specify "allows adding headers" do 32 | app = Proc.new {[200, {'X-Foo' => 'foo'}, []]} 33 | middleware = response_header(app) do |headers| 34 | headers['X-Bar'] = 'bar' 35 | end 36 | r = middleware.call(env) 37 | if Rack.release < "3" 38 | _(r[1]).must_equal('X-Foo' => 'foo', 'X-Bar' => 'bar') 39 | else 40 | _(r[1]).must_equal('x-foo' => 'foo', 'x-bar' => 'bar') 41 | end 42 | end 43 | 44 | specify "allows deleting headers" do 45 | app = Proc.new {[200, {'X-Foo' => 'foo', 'X-Bar' => 'bar'}, []]} 46 | middleware = response_header(app) do |headers| 47 | headers.delete('X-Bar') 48 | end 49 | r = middleware.call(env) 50 | if Rack.release < "3" 51 | _(r[1]).must_equal('X-Foo' => 'foo') 52 | else 53 | _(r[1]).must_equal('x-foo' => 'foo') 54 | end 55 | end 56 | 57 | end 58 | -------------------------------------------------------------------------------- /test/spec_rack_try_static.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | 5 | require 'rack' 6 | require 'rack/contrib/try_static' 7 | require 'rack/mock' 8 | 9 | def build_options(opts) 10 | { 11 | :urls => %w[/], 12 | :root => ::File.expand_path(::File.dirname(__FILE__)), 13 | }.merge(opts) 14 | end 15 | 16 | def request(options = {}) 17 | @request = 18 | Rack::MockRequest.new( 19 | Rack::Lint.new( 20 | Rack::TryStatic.new( 21 | lambda { |_| [200, {}, ["Hello World"]]}, 22 | options))) 23 | end 24 | 25 | describe "Rack::TryStatic" do 26 | describe 'when file cannot be found' do 27 | it 'should call call app' do 28 | res = request(build_options(:try => ['html'])).get('/statics') 29 | _(res.ok?).must_equal(true) 30 | _(res.body).must_equal "Hello World" 31 | end 32 | end 33 | 34 | describe 'when file can be found' do 35 | it 'should serve first found' do 36 | res = request(build_options(:try => ['.html', '/index.html', '/index.htm'])).get('/statics') 37 | _(res.ok?).must_equal(true) 38 | _(res.body.strip).must_equal "index.html" 39 | end 40 | end 41 | 42 | describe 'when path_info maps directly to file' do 43 | it 'should serve existing' do 44 | res = request(build_options(:try => ['/index.html'])).get('/statics/existing.html') 45 | _(res.ok?).must_equal(true) 46 | _(res.body.strip).must_equal "existing.html" 47 | end 48 | end 49 | 50 | describe 'when sharing options' do 51 | it 'should not mutate given options' do 52 | org_options = build_options :try => ['/index.html'] 53 | given_options = org_options.dup 54 | _(request(given_options).get('/statics').ok?).must_equal(true) 55 | _(request(given_options).get('/statics').ok?).must_equal(true) 56 | _(given_options).must_equal org_options 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/spec_rack_host_meta.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/host_meta' 6 | require 'rack/contrib/not_found' 7 | 8 | describe "Rack::HostMeta" do 9 | 10 | before do 11 | app = Rack::Builder.new do 12 | use Rack::Lint 13 | use Rack::ContentLength 14 | use Rack::HostMeta do 15 | link :uri => '/robots.txt', :rel => 'robots' 16 | link :uri => '/w3c/p3p.xml', :rel => 'privacy', :type => 'application/p3p.xml' 17 | link :pattern => '{uri};json_schema', :rel => 'describedby', :type => 'application/x-schema+json' 18 | end 19 | run Rack::NotFound.new('test/404.html') 20 | end 21 | @response = Rack::MockRequest.new(app).get('/host-meta') 22 | end 23 | 24 | specify "should respond to /host-meta" do 25 | _(@response.status).must_equal 200 26 | end 27 | 28 | specify "should respond with the correct media type" do 29 | _(@response['Content-Type']).must_equal 'application/host-meta' 30 | end 31 | 32 | specify "should include a Link entry for each Link item in the config block" do 33 | _(@response.body).must_match(/Link:\s*<\/robots.txt>;.*\n/) 34 | _(@response.body).must_match(/Link:\s*<\/w3c\/p3p.xml>;.*/) 35 | end 36 | 37 | specify "should include a Link-Pattern entry for each Link-Pattern item in the config" do 38 | _(@response.body).must_match(/Link-Pattern:\s*<\{uri\};json_schema>;.*/) 39 | end 40 | 41 | specify "should include a rel attribute for each Link or Link-Pattern entry where specified" do 42 | _(@response.body).must_match(/rel="robots"/) 43 | _(@response.body).must_match(/rel="privacy"/) 44 | _(@response.body).must_match(/rel="describedby"/) 45 | end 46 | 47 | specify "should include a type attribute for each Link or Link-Pattern entry where specified" do 48 | _(@response.body).must_match(/Link:\s*<\/w3c\/p3p.xml>;.*type.*application\/p3p.xml/) 49 | _(@response.body).must_match(/Link-Pattern:\s*<\{uri\};json_schema>;.*type.*application\/x-schema\+json/) 50 | end 51 | 52 | end 53 | -------------------------------------------------------------------------------- /test/spec_rack_nested_params.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/nested_params' 6 | require 'rack/method_override' 7 | 8 | describe Rack::NestedParams do 9 | 10 | request_object = nil 11 | App = lambda { |env| request_object = Rack::Request.new(env); [200, {'content-type' => 'text/plain'}, []] } 12 | 13 | def env_for_post_with_headers(path, headers, body) 14 | Rack::MockRequest.env_for(path, {:method => "POST", :input => body}.merge(headers)) 15 | end 16 | 17 | def form_post(params, content_type = 'application/x-www-form-urlencoded') 18 | params = Rack::Utils.build_query(params) if Hash === params 19 | env_for_post_with_headers('/', {'CONTENT_TYPE' => content_type}, params) 20 | end 21 | 22 | def middleware 23 | # Rack::Lint can't be used because it does not rewind the body 24 | Rack::NestedParams.new(App) 25 | end 26 | 27 | specify "should handle requests with POST body content-type of application/x-www-form-urlencoded" do 28 | req = middleware.call(form_post({'foo[bar][baz]' => 'nested'})).last 29 | _(request_object.POST).must_equal({"foo" => { "bar" => { "baz" => "nested" }}}) 30 | end 31 | 32 | specify "should not parse requests with other content-type" do 33 | req = middleware.call(form_post({'foo[bar][baz]' => 'nested'}, 'text/plain')).last 34 | _(request_object.POST).must_equal({}) 35 | end 36 | 37 | specify "should work even after another middleware already parsed the request" do 38 | app = Rack::MethodOverride.new(middleware) 39 | req = app.call(form_post({'_method' => 'put', 'foo[bar]' => 'nested'})).last 40 | _(request_object.POST).must_equal({'_method' => 'put', "foo" => { "bar" => "nested" }}) 41 | _(request_object.put?).must_equal true 42 | end 43 | 44 | specify "should make last boolean have precedence even after request already parsed" do 45 | app = Rack::MethodOverride.new(middleware) 46 | req = app.call(form_post("foo=1&foo=0")).last 47 | _(request_object.POST).must_equal({"foo" => "0"}) 48 | end 49 | 50 | end 51 | -------------------------------------------------------------------------------- /test/spec_rack_not_found.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/not_found' 6 | require 'tempfile' 7 | 8 | describe "Rack::NotFound" do 9 | 10 | specify "should render the file at the given path for all requests" do 11 | app = Rack::Builder.new do 12 | use Rack::Lint 13 | run Rack::NotFound.new('test/404.html') 14 | end 15 | response = Rack::MockRequest.new(app).get('/') 16 | _(response.body).must_equal('Custom 404 page content') 17 | _(response.headers['Content-Length']).must_equal('23') 18 | _(response.headers['Content-Type']).must_equal('text/html') 19 | _(response.status).must_equal(404) 20 | end 21 | 22 | specify "should render the default response body if no path specified" do 23 | app = Rack::Builder.new do 24 | use Rack::Lint 25 | run Rack::NotFound.new 26 | end 27 | response = Rack::MockRequest.new(app).get('/') 28 | _(response.body).must_equal("Not found\n") 29 | _(response.headers['Content-Length']).must_equal('10') 30 | _(response.headers['Content-Type']).must_equal('text/html') 31 | _(response.status).must_equal(404) 32 | end 33 | 34 | specify "should accept an alternate content type" do 35 | app = Rack::Builder.new do 36 | use Rack::Lint 37 | run Rack::NotFound.new(nil, 'text/plain') 38 | end 39 | response = Rack::MockRequest.new(app).get('/') 40 | _(response.body).must_equal("Not found\n") 41 | _(response.headers['Content-Length']).must_equal('10') 42 | _(response.headers['Content-Type']).must_equal('text/plain') 43 | _(response.status).must_equal(404) 44 | end 45 | 46 | specify "should return correct size" do 47 | Tempfile.open('test') do |f| 48 | f.write '' 49 | f.write '' 50 | f.write '☃ snowman' 51 | f.close 52 | app = Rack::Builder.new do 53 | use Rack::Lint 54 | run Rack::NotFound.new(f.path) 55 | end 56 | response = Rack::MockRequest.new(app).get('/') 57 | _(response.headers['Content-Length']).must_equal('46') 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/spec_rack_profiler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | 6 | begin 7 | require 'rack/contrib/profiler' 8 | 9 | describe 'Rack::Profiler' do 10 | app = lambda { |env| Time.new; [200, {'content-type' => 'text/plain'}, ['Oh hai der']] } 11 | request = Rack::MockRequest.env_for("/", :params => "profile=process_time") 12 | 13 | def profiler(app, options = {}) 14 | Rack::Lint.new(Rack::Profiler.new(app, options)) 15 | end 16 | 17 | specify 'printer defaults to RubyProf::CallStackPrinter' do 18 | profiler = Rack::Profiler.new(nil, {}) # Don't use Rack::Lint to haev access to the middleware instance variable 19 | _(profiler.instance_variable_get('@printer')).must_equal RubyProf::CallStackPrinter 20 | _(profiler.instance_variable_get('@times')).must_equal 1 21 | end 22 | 23 | specify 'called multiple times via query params' do 24 | runs = 4 25 | req = Rack::MockRequest.env_for("/", :params => "profile=process_time&profiler_runs=#{runs}") 26 | body = profiler(app).call(req)[2] 27 | _(body.to_enum.to_a.join).must_match(/\[#{runs} calls, #{runs} total\]/) 28 | end 29 | 30 | specify 'called more than the default maximum times via query params' do 31 | runs = 20 32 | req = Rack::MockRequest.env_for("/", :params => "profile=process_time&profiler_runs=#{runs}") 33 | body = profiler(app).call(req)[2] 34 | _(body.to_enum.to_a.join).must_match(/\[10 calls, 10 total\]/) 35 | end 36 | 37 | specify 'CallStackPrinter has content-type test/html' do 38 | headers = profiler(app, :printer => :call_stack).call(request)[1] 39 | _(headers).must_equal "content-type"=>"text/html" 40 | end 41 | 42 | specify 'FlatPrinter and GraphPrinter has content-type text/plain' do 43 | %w(flat graph).each do |printer| 44 | headers = profiler(app, :printer => printer.to_sym).call(request)[1] 45 | _(headers).must_equal "content-type"=>"text/plain" 46 | end 47 | end 48 | 49 | specify 'GraphHtmlPrinter has content-type text/html' do 50 | headers = profiler(app, :printer => :graph_html).call(request)[1] 51 | _(headers).must_equal "content-type"=>"text/html" 52 | end 53 | end 54 | 55 | rescue LoadError => boom 56 | $stderr.puts "WARN: Skipping Rack::Profiler tests (ruby-prof not installed)" 57 | end 58 | -------------------------------------------------------------------------------- /lib/rack/contrib/relative_redirect.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rack' 4 | 5 | # Rack::RelativeRedirect is a simple middleware that converts relative paths in 6 | # redirects in absolute urls, so they conform to RFC2616. It allows the user to 7 | # specify the absolute path to use (with a sensible default), and handles 8 | # relative paths (those that don't start with a slash) as well. 9 | class Rack::RelativeRedirect 10 | SCHEME_MAP = {'http'=>'80', 'https'=>'443'} 11 | # The default proc used if a block is not provided to .new 12 | # Just uses the url scheme of the request and the server name. 13 | DEFAULT_ABSOLUTE_PROC = proc do |env, res| 14 | port = env['SERVER_PORT'] 15 | scheme = env['rack.url_scheme'] 16 | "#{scheme}://#{env['SERVER_NAME']}#{":#{port}" unless SCHEME_MAP[scheme] == port}" 17 | end 18 | 19 | # Initialize a new RelativeRedirect object with the given arguments. Arguments: 20 | # * app : The next middleware in the chain. This is always called. 21 | # * &block : If provided, it is called with the environment and the response 22 | # from the next middleware. It should return a string representing the scheme 23 | # and server name (such as 'http://example.org'). 24 | def initialize(app, &block) 25 | @app = app 26 | @absolute_proc = block || DEFAULT_ABSOLUTE_PROC 27 | end 28 | 29 | # Call the next middleware with the environment. If the request was a 30 | # redirect (response status 301, 302, or 303), and the location header does 31 | # not start with an http or https url scheme, call the block provided by new 32 | # and use that to make the Location header an absolute url. If the Location 33 | # does not start with a slash, make location relative to the path requested. 34 | def call(env) 35 | status, headers, body = @app.call(env) 36 | headers_klass = Rack.release < "3" ? Rack::Utils::HeaderHash : Rack::Headers 37 | headers = headers_klass.new.merge(headers) 38 | 39 | if [301,302,303, 307,308].include?(status) and loc = headers['Location'] and !%r{\Ahttps?://}o.match(loc) 40 | absolute = @absolute_proc.call(env, [status, headers, body]) 41 | headers['Location'] = if %r{\A/}.match(loc) 42 | "#{absolute}#{loc}" 43 | else 44 | "#{absolute}#{File.dirname(Rack::Utils.unescape(env['PATH_INFO']))}/#{loc}" 45 | end 46 | end 47 | 48 | [status, headers, body] 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/spec_rack_enforce_valid_encoding.rb: -------------------------------------------------------------------------------- 1 | # -*- encoding : us-ascii -*- 2 | # frozen_string_literal: true 3 | 4 | require 'minitest/autorun' 5 | require 'rack/contrib/enforce_valid_encoding' 6 | 7 | if "a string".respond_to?(:valid_encoding?) 8 | require 'rack/mock' 9 | require 'rack/contrib/enforce_valid_encoding' 10 | 11 | VALID_PATH = "h%C3%A4ll%C3%B2" 12 | INVALID_PATH = "/%D1%A1%D4%F1%D7%A2%B2%E1%D3%C3%BB%A7%C3%FB" 13 | 14 | describe "Rack::EnforceValidEncoding" do 15 | before do 16 | @app = Rack::Lint.new( 17 | Rack::EnforceValidEncoding.new( 18 | lambda do |env| 19 | [200, {'content-type'=>'text/plain'}, ['Hello World']] 20 | end 21 | ) 22 | ) 23 | end 24 | 25 | describe "contstant assertions" do 26 | it "INVALID_PATH should not be a valid UTF-8 string when decoded" do 27 | _(Rack::Utils.unescape(INVALID_PATH).valid_encoding?).must_equal false 28 | end 29 | 30 | it "VALID_PATH should be valid when decoded" do 31 | _(Rack::Utils.unescape(VALID_PATH).valid_encoding?).must_equal true 32 | end 33 | end 34 | 35 | it "should accept a request with a correctly encoded path" do 36 | response = Rack::MockRequest.new(@app).get(VALID_PATH) 37 | _(response.body).must_equal("Hello World") 38 | _(response.status).must_equal(200) 39 | end 40 | 41 | it "should reject a request with a poorly encoded path" do 42 | response = Rack::MockRequest.new(@app).get(INVALID_PATH) 43 | _(response.status).must_equal(400) 44 | end 45 | 46 | it "should accept a request with a correctly encoded query string" do 47 | response = Rack::MockRequest.new(@app).get('/', 'QUERY_STRING' => VALID_PATH) 48 | _(response.body).must_equal("Hello World") 49 | _(response.status).must_equal(200) 50 | end 51 | 52 | it "should reject a request with a poorly encoded query string" do 53 | response = Rack::MockRequest.new(@app).get('/', 'QUERY_STRING' => INVALID_PATH) 54 | _(response.status).must_equal(400) 55 | end 56 | 57 | it "should reject a request containing malformed multibyte characters" do 58 | response = Rack::MockRequest.new(@app).get('/', 'QUERY_STRING' => Rack::Utils.unescape(INVALID_PATH, Encoding::ASCII_8BIT)) 59 | _(response.status).must_equal(400) 60 | end 61 | end 62 | else 63 | STDERR.puts "WARN: Skipping Rack::EnforceValidEncoding tests (String#valid_encoding? not available)" 64 | end 65 | -------------------------------------------------------------------------------- /lib/rack/contrib/access.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ipaddr" 4 | 5 | module Rack 6 | 7 | ## 8 | # Rack middleware for limiting access based on IP address 9 | # 10 | # 11 | # === Options: 12 | # 13 | # path => ipmasks ipmasks: Array of remote addresses which are allowed to access 14 | # 15 | # === Examples: 16 | # 17 | # use Rack::Access, '/backend' => [ '127.0.0.1', '192.168.1.0/24' ] 18 | # 19 | # 20 | 21 | class Access 22 | 23 | attr_reader :options 24 | 25 | def initialize(app, options = {}) 26 | @app = app 27 | mapping = options.empty? ? {"/" => ["127.0.0.1"]} : options 28 | @mapping = remap(mapping) 29 | end 30 | 31 | def remap(mapping) 32 | mapping.map { |location, ipmasks| 33 | if location =~ %r{\Ahttps?://(.*?)(/.*)} 34 | host, location = $1, $2 35 | else 36 | host = nil 37 | end 38 | 39 | unless location[0] == ?/ 40 | raise ArgumentError, "paths need to start with /" 41 | end 42 | location = location.chomp('/') 43 | match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", Regexp::NOENCODING) 44 | 45 | ipmasks.collect! do |ipmask| 46 | ipmask.is_a?(IPAddr) ? ipmask : IPAddr.new(ipmask) 47 | end 48 | [host, location, match, ipmasks] 49 | }.sort_by { |(h, l, m, a)| [h ? -h.size : (-1.0 / 0.0), -l.size] } # Longest path first 50 | end 51 | 52 | def call(env) 53 | request = Request.new(env) 54 | ipmasks = ipmasks_for_path(env) 55 | return forbidden! unless ip_authorized?(request, ipmasks) 56 | status, headers, body = @app.call(env) 57 | [status, headers, body] 58 | end 59 | 60 | def ipmasks_for_path(env) 61 | path = env["PATH_INFO"].to_s 62 | hHost, sName, sPort = env.values_at('HTTP_HOST','SERVER_NAME','SERVER_PORT') 63 | @mapping.each do |host, location, match, ipmasks| 64 | next unless (hHost == host || sName == host \ 65 | || (host.nil? && (hHost == sName || hHost == sName+':'+sPort))) 66 | next unless path =~ match && rest = $1 67 | next unless rest.empty? || rest[0] == ?/ 68 | 69 | return ipmasks 70 | end 71 | nil 72 | end 73 | 74 | def forbidden! 75 | [403, { 'content-type' => 'text/html', 'content-length' => '0' }, []] 76 | end 77 | 78 | def ip_authorized?(request, ipmasks) 79 | return true if ipmasks.nil? 80 | 81 | ipmasks.any? do |ip_mask| 82 | ip_mask.include?(IPAddr.new(request.ip)) 83 | end 84 | end 85 | 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/rack/contrib.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rack' 4 | 5 | module Rack 6 | module Contrib 7 | def self.release 8 | require "git-version-bump" 9 | GVB.version 10 | rescue LoadError 11 | begin 12 | Gem::Specification.find_by_name("rack-contrib").version.to_s 13 | rescue Gem::LoadError 14 | "0.0.0.1.ENOGEM" 15 | end 16 | end 17 | end 18 | 19 | autoload :Access, "rack/contrib/access" 20 | autoload :BounceFavicon, "rack/contrib/bounce_favicon" 21 | autoload :Cookies, "rack/contrib/cookies" 22 | autoload :CSSHTTPRequest, "rack/contrib/csshttprequest" 23 | autoload :Deflect, "rack/contrib/deflect" 24 | autoload :EnforceValidEncoding, "rack/contrib/enforce_valid_encoding" 25 | autoload :ExpectationCascade, "rack/contrib/expectation_cascade" 26 | autoload :HostMeta, "rack/contrib/host_meta" 27 | autoload :GarbageCollector, "rack/contrib/garbagecollector" 28 | autoload :JSONP, "rack/contrib/jsonp" 29 | autoload :JSONBodyParser, "rack/contrib/json_body_parser" 30 | autoload :LazyConditionalGet, "rack/contrib/lazy_conditional_get" 31 | autoload :LighttpdScriptNameFix, "rack/contrib/lighttpd_script_name_fix" 32 | autoload :Locale, "rack/contrib/locale" 33 | autoload :MailExceptions, "rack/contrib/mailexceptions" 34 | autoload :PostBodyContentTypeParser, "rack/contrib/post_body_content_type_parser" 35 | autoload :ProcTitle, "rack/contrib/proctitle" 36 | autoload :Profiler, "rack/contrib/profiler" 37 | autoload :ResponseHeaders, "rack/contrib/response_headers" 38 | autoload :Signals, "rack/contrib/signals" 39 | autoload :SimpleEndpoint, "rack/contrib/simple_endpoint" 40 | autoload :TimeZone, "rack/contrib/time_zone" 41 | autoload :Evil, "rack/contrib/evil" 42 | autoload :Callbacks, "rack/contrib/callbacks" 43 | autoload :NestedParams, "rack/contrib/nested_params" 44 | autoload :Config, "rack/contrib/config" 45 | autoload :NotFound, "rack/contrib/not_found" 46 | autoload :ResponseCache, "rack/contrib/response_cache" 47 | autoload :RelativeRedirect, "rack/contrib/relative_redirect" 48 | autoload :StaticCache, "rack/contrib/static_cache" 49 | autoload :TryStatic, "rack/contrib/try_static" 50 | autoload :Printout, "rack/contrib/printout" 51 | end 52 | -------------------------------------------------------------------------------- /test/spec_rack_cookies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/cookies' 6 | 7 | describe "Rack::Cookies" do 8 | def cookies(app) 9 | Rack::Lint.new(Rack::Cookies.new(app)) 10 | end 11 | 12 | specify "should be able to read received cookies" do 13 | app = cookies( 14 | lambda { |env| 15 | cookies = env['rack.cookies'] 16 | foo, quux = cookies[:foo], cookies['quux'] 17 | [200, {'Content-Type' => 'text/plain'}, ["foo: #{foo}, quux: #{quux}"]] 18 | } 19 | ) 20 | 21 | response = Rack::MockRequest.new(app).get('/', 'HTTP_COOKIE' => 'foo=bar;quux=h&m') 22 | _(response.body).must_equal('foo: bar, quux: h&m') 23 | end 24 | 25 | specify "should be able to set new cookies" do 26 | app = cookies( 27 | lambda { |env| 28 | cookies = env['rack.cookies'] 29 | cookies[:foo] = 'bar' 30 | cookies['quux'] = 'h&m' 31 | [200, {'Content-Type' => 'text/plain'}, []] 32 | } 33 | ) 34 | 35 | response = Rack::MockRequest.new(app).get('/') 36 | if Rack.release < "3" 37 | _(response.headers['Set-Cookie'].split("\n").sort).must_equal(["foo=bar; path=/","quux=h%26m; path=/"]) 38 | else 39 | _(response.headers['Set-Cookie'].sort).must_equal(["foo=bar; path=/","quux=h%26m; path=/"]) 40 | end 41 | end 42 | 43 | specify "should be able to set cookie with options" do 44 | app = cookies( 45 | lambda { |env| 46 | cookies = env['rack.cookies'] 47 | cookies['foo'] = { :value => 'bar', :path => '/login', :secure => true } 48 | [200, {'Content-Type' => 'text/plain'}, []] 49 | } 50 | ) 51 | 52 | response = Rack::MockRequest.new(app).get('/') 53 | _(response.headers['Set-Cookie']).must_equal('foo=bar; path=/login; secure') 54 | end 55 | 56 | specify "should be able to delete received cookies" do 57 | app = cookies( 58 | lambda { |env| 59 | cookies = env['rack.cookies'] 60 | cookies.delete(:foo) 61 | foo, quux = cookies['foo'], cookies[:quux] 62 | [200, {'Content-Type' => 'text/plain'}, ["foo: #{foo}, quux: #{quux}"]] 63 | } 64 | ) 65 | 66 | response = Rack::MockRequest.new(app).get('/', 'HTTP_COOKIE' => 'foo=bar;quux=h&m') 67 | _(response.body).must_equal('foo: , quux: h&m') 68 | _(response.headers['Set-Cookie']).must_match(/foo=(;|$)/) 69 | # This test is currently failing; I suspect it is due to a bug in a dependent 70 | # lib's cookie handling code, but I haven't had time to track it down yet 71 | # -- @mpalmer, 2015-06-17 72 | # response.headers['Set-Cookie'].must_match(/expires=Thu, 01 Jan 1970 00:00:00 GMT/) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/rack/contrib/simple_endpoint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | # Create simple endpoints with routing rules, similar to Sinatra actions. 5 | # 6 | # Simplest example: 7 | # 8 | # use Rack::SimpleEndpoint, '/ping_monitor' do 9 | # 'pong' 10 | # end 11 | # 12 | # The value returned from the block will be written to the response body, so 13 | # the above example will return "pong" when the request path is /ping_monitor. 14 | # 15 | # HTTP verb requirements can optionally be specified: 16 | # 17 | # use Rack::SimpleEndpoint, '/foo' => :get do 18 | # 'only GET requests will match' 19 | # end 20 | # 21 | # use Rack::SimpleEndpoint, '/bar' => [:get, :post] do 22 | # 'only GET and POST requests will match' 23 | # end 24 | # 25 | # Rack::Request and Rack::Response objects are yielded to block: 26 | # 27 | # use Rack::SimpleEndpoint, '/json' do |req, res| 28 | # res['Content-Type'] = 'application/json' 29 | # %({"foo": "#{req[:foo]}"}) 30 | # end 31 | # 32 | # When path is a Regexp, match data object is yielded as third argument to block 33 | # 34 | # use Rack::SimpleEndpoint, %r{^/(john|paul|george|ringo)} do |req, res, match| 35 | # "Hello, #{match[1]}" 36 | # end 37 | # 38 | # A :pass symbol returned from block will not return a response; control will continue down the 39 | # Rack stack: 40 | # 41 | # use Rack::SimpleEndpoint, '/api_key' do |req, res| 42 | # req.env['myapp.user'].authorized? ? '12345' : :pass 43 | # end 44 | # 45 | # # Unauthorized access to /api_key will be handled by PublicApp 46 | # run PublicApp 47 | class SimpleEndpoint 48 | def initialize(app, arg, &block) 49 | @app = app 50 | @path = extract_path(arg) 51 | @verbs = extract_verbs(arg) 52 | @block = block 53 | end 54 | 55 | def call(env) 56 | match = match_path(env['PATH_INFO']) 57 | if match && valid_method?(env['REQUEST_METHOD']) 58 | req, res = Request.new(env), Response.new 59 | body = @block.call(req, res, (match unless match == true)) 60 | body == :pass ? @app.call(env) : (res.write(body); res.finish) 61 | else 62 | @app.call(env) 63 | end 64 | end 65 | 66 | private 67 | def extract_path(arg) 68 | arg.is_a?(Hash) ? arg.keys.first : arg 69 | end 70 | 71 | def extract_verbs(arg) 72 | arg.is_a?(Hash) ? [arg.values.first].flatten.map {|verb| verb.to_s.upcase} : [] 73 | end 74 | 75 | def match_path(path) 76 | @path.is_a?(Regexp) ? @path.match(path.to_s) : @path == path.to_s 77 | end 78 | 79 | def valid_method?(method) 80 | @verbs.empty? || @verbs.include?(method) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/spec_rack_post_body_content_type_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | 6 | begin 7 | require 'rack/contrib/post_body_content_type_parser' 8 | 9 | describe "Rack::PostBodyContentTypeParser" do 10 | 11 | specify "should parse 'application/json' requests" do 12 | params = params_for_request '{"key":"value"}', "application/json" 13 | _(params['key']).must_equal "value" 14 | end 15 | 16 | specify "should parse 'application/json; charset=utf-8' requests" do 17 | params = params_for_request '{"key":"value"}', "application/json; charset=utf-8" 18 | _(params['key']).must_equal "value" 19 | end 20 | 21 | specify "should parse 'application/json' requests with empty body" do 22 | params = params_for_request "", "application/json" 23 | _(params).must_equal({}) 24 | end 25 | 26 | specify "shouldn't affect form-urlencoded requests" do 27 | params = params_for_request("key=value", "application/x-www-form-urlencoded") 28 | _(params['key']).must_equal "value" 29 | end 30 | 31 | specify "should apply given block to body" do 32 | params = params_for_request '{"key":"value"}', "application/json" do |body| 33 | { 'payload' => JSON.parse(body) } 34 | end 35 | _(params['payload']).wont_be_nil 36 | _(params['payload']['key']).must_equal "value" 37 | end 38 | 39 | describe "contradiction between body and type" do 40 | def assert_failed_to_parse_as_json(response) 41 | _(response).wont_be_nil 42 | status, headers, body = response 43 | _(status).must_equal 400 44 | _(body.to_enum.to_a).must_equal ["failed to parse body as JSON"] 45 | end 46 | 47 | specify "should return bad request with invalid JSON" do 48 | test_body = '"bar":"foo"}' 49 | env = Rack::MockRequest.env_for "/", {:method => "POST", :input => test_body, "CONTENT_TYPE" => 'application/json'} 50 | app = lambda { |env| [200, {'content-type' => 'text/plain'}, []] } 51 | response = Rack::Lint.new(Rack::PostBodyContentTypeParser.new(app)).call(env) 52 | 53 | assert_failed_to_parse_as_json(response) 54 | end 55 | end 56 | end 57 | 58 | def params_for_request(body, content_type, &block) 59 | params = nil 60 | env = Rack::MockRequest.env_for "/", {:method => "POST", :input => body, "CONTENT_TYPE" => content_type} 61 | app = lambda { |env| params = Rack::Request.new(env).POST; [200, {'content-type' => 'text/plain'}, []] } 62 | Rack::Lint.new(Rack::PostBodyContentTypeParser.new(app, &block)).call(env) 63 | params 64 | end 65 | 66 | rescue LoadError => e 67 | # Missing dependency JSON, skipping tests. 68 | STDERR.puts "WARN: Skipping Rack::PostBodyContentTypeParser tests (json not installed)" 69 | end 70 | -------------------------------------------------------------------------------- /lib/rack/contrib/post_body_content_type_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require 'json' 5 | rescue LoadError => e 6 | require 'json/pure' 7 | end 8 | 9 | module Rack 10 | 11 | # DEPRECATED: JSONBodyParser is a drop-in replacement that is faster and more configurable. 12 | # 13 | # A Rack middleware for parsing POST/PUT body data when Content-Type is 14 | # not one of the standard supported types, like application/json. 15 | # 16 | # === How to use the middleware 17 | # 18 | # Example of simple +config.ru+ file: 19 | # 20 | # require 'rack' 21 | # require 'rack/contrib' 22 | # 23 | # use ::Rack::PostBodyContentTypeParser 24 | # 25 | # app = lambda do |env| 26 | # request = Rack::Request.new(env) 27 | # body = "Hello #{request.params['name']}" 28 | # [200, {'Content-Type' => 'text/plain'}, [body]] 29 | # end 30 | # 31 | # run app 32 | # 33 | # Example with passing block argument: 34 | # 35 | # use ::Rack::PostBodyContentTypeParser do |body| 36 | # { 'params' => JSON.parse(body) } 37 | # end 38 | # 39 | # Example with passing proc argument: 40 | # 41 | # parser = ->(body) { { 'params' => JSON.parse(body) } } 42 | # use ::Rack::PostBodyContentTypeParser, &parser 43 | # 44 | # 45 | # === Failed JSON parsing 46 | # 47 | # Returns "400 Bad request" response if invalid JSON document was sent: 48 | # 49 | # Raw HTTP response: 50 | # 51 | # HTTP/1.1 400 Bad Request 52 | # Content-Type: text/plain 53 | # Content-Length: 28 54 | # 55 | # failed to parse body as JSON 56 | # 57 | class PostBodyContentTypeParser 58 | 59 | # Constants 60 | # 61 | CONTENT_TYPE = 'CONTENT_TYPE'.freeze 62 | POST_BODY = 'rack.input'.freeze 63 | FORM_INPUT = 'rack.request.form_input'.freeze 64 | FORM_HASH = 'rack.request.form_hash'.freeze 65 | 66 | # Supported Content-Types 67 | # 68 | APPLICATION_JSON = 'application/json'.freeze 69 | 70 | def initialize(app, &block) 71 | warn "[DEPRECATION] `PostBodyContentTypeParser` is deprecated. Use `JSONBodyParser` as a drop-in replacement." 72 | @app = app 73 | @block = block || Proc.new { |body| JSON.parse(body, :create_additions => false) } 74 | end 75 | 76 | def call(env) 77 | if Rack::Request.new(env).media_type == APPLICATION_JSON && (body = env[POST_BODY].read).length != 0 78 | env[POST_BODY].rewind if env[POST_BODY].respond_to?(:rewind) # somebody might try to read this stream 79 | env.update(FORM_HASH => @block.call(body), FORM_INPUT => env[POST_BODY]) 80 | end 81 | @app.call(env) 82 | rescue JSON::ParserError 83 | bad_request('failed to parse body as JSON') 84 | end 85 | 86 | def bad_request(body = 'Bad Request') 87 | [ 400, { 'content-type' => 'text/plain', 'content-length' => body.bytesize.to_s }, [body] ] 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/rack/contrib/locale.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'i18n' 4 | 5 | module Rack 6 | class Locale 7 | HEADERS_KLASS = Rack.release < "3" ? Utils::HeaderHash : Headers 8 | private_constant :HEADERS_KLASS 9 | 10 | def initialize(app) 11 | @app = app 12 | end 13 | 14 | def call(env) 15 | locale_to_restore = I18n.locale 16 | 17 | locale = user_preferred_locale(env["HTTP_ACCEPT_LANGUAGE"]) 18 | locale ||= I18n.default_locale 19 | 20 | env['rack.locale'] = I18n.locale = locale.to_s 21 | status, headers, body = @app.call(env) 22 | headers = HEADERS_KLASS.new.merge(headers) 23 | 24 | unless headers['Content-Language'] 25 | headers['Content-Language'] = locale.to_s 26 | end 27 | 28 | [status, headers, body] 29 | ensure 30 | I18n.locale = locale_to_restore 31 | end 32 | 33 | private 34 | 35 | # Accept-Language header is covered mainly by RFC 7231 36 | # https://tools.ietf.org/html/rfc7231 37 | # 38 | # Related sections: 39 | # 40 | # * https://tools.ietf.org/html/rfc7231#section-5.3.1 41 | # * https://tools.ietf.org/html/rfc7231#section-5.3.5 42 | # * https://tools.ietf.org/html/rfc4647#section-3.4 43 | # 44 | # There is an obsolete RFC 2616 (https://tools.ietf.org/html/rfc2616) 45 | # 46 | # Edge cases: 47 | # 48 | # * Value can be a comma separated list with optional whitespaces: 49 | # Accept-Language: da, en-gb;q=0.8, en;q=0.7 50 | # 51 | # * Quality value can contain optional whitespaces as well: 52 | # Accept-Language: ru-UA, ru; q=0.8, uk; q=0.6, en-US; q=0.4, en; q=0.2 53 | # 54 | # * Quality prefix 'q=' can be in upper case (Q=) 55 | # 56 | # * Ignore case when match locale with I18n available locales 57 | # 58 | def user_preferred_locale(header) 59 | return if header.nil? 60 | 61 | locales = header.gsub(/\s+/, '').split(",").map do |language_tag| 62 | locale, quality = language_tag.split(/;q=/i) 63 | quality = quality ? quality.to_f : 1.0 64 | [locale, quality] 65 | end.reject do |(locale, quality)| 66 | locale == '*' || quality == 0 67 | end.sort_by do |(_, quality)| 68 | quality 69 | end.map(&:first) 70 | 71 | return if locales.empty? 72 | 73 | if I18n.enforce_available_locales 74 | locale = locales.reverse.find { |locale| I18n.available_locales.any? { |al| match?(al, locale) } } 75 | matched_locale = I18n.available_locales.find { |al| match?(al, locale) } if locale 76 | if !locale && !matched_locale 77 | matched_locale = locales.reverse.find { |locale| I18n.available_locales.any? { |al| variant_match?(al, locale) } } 78 | matched_locale = matched_locale[0,2].downcase if matched_locale 79 | end 80 | matched_locale 81 | else 82 | locales.last 83 | end 84 | end 85 | 86 | def match?(s1, s2) 87 | s1.to_s.casecmp(s2.to_s) == 0 88 | end 89 | 90 | def variant_match?(s1, s2) 91 | s1.to_s.casecmp(s2[0,2].to_s) == 0 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/spec_rack_expectation_cascade.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/expectation_cascade' 6 | 7 | describe "Rack::ExpectationCascade" do 8 | def expectation_cascade(&block) 9 | Rack::Lint.new(Rack::ExpectationCascade.new(&block)) 10 | end 11 | 12 | specify "with no apps returns a 404 if no expectation header was set" do 13 | app = expectation_cascade 14 | env = Rack::MockRequest.env_for 15 | response = app.call(env) 16 | _(response[0]).must_equal 404 17 | _(env['Expect']).must_be_nil 18 | end 19 | 20 | specify "with no apps returns a 417 if expectation header was set" do 21 | app = expectation_cascade 22 | env = Rack::MockRequest.env_for('', "HTTP_EXPECT" => "100-continue") 23 | response = app.call(env) 24 | _(response[0]).must_equal 417 25 | _(env['HTTP_EXPECT']).must_equal('100-continue') 26 | end 27 | 28 | specify "returns first successful response" do 29 | app = expectation_cascade do |cascade| 30 | cascade << lambda { |env| [417, {"content-type" => "text/plain"}, []] } 31 | cascade << lambda { |env| [200, {"content-type" => "text/plain"}, ["OK"]] } 32 | end 33 | env = Rack::MockRequest.env_for 34 | response = app.call(env) 35 | _(response[0]).must_equal 200 36 | _(response[2].to_enum.to_a).must_equal ["OK"] 37 | end 38 | 39 | specify "expectation is set if it has not been already" do 40 | app = expectation_cascade do |cascade| 41 | cascade << lambda { |env| [200, {"content-type" => "text/plain"}, ["Expect: #{env["HTTP_EXPECT"]}"]] } 42 | end 43 | env = Rack::MockRequest.env_for 44 | response = app.call(env) 45 | _(response[0]).must_equal 200 46 | _(response[2].to_enum.to_a).must_equal ["Expect: 100-continue"] 47 | end 48 | 49 | specify "returns a 404 if no apps where matched and no expectation header was set" do 50 | app = expectation_cascade do |cascade| 51 | cascade << lambda { |env| [417, {"content-type" => "text/plain"}, []] } 52 | end 53 | env = Rack::MockRequest.env_for 54 | response = app.call(env) 55 | _(response[0]).must_equal 404 56 | _(response[2].to_enum.to_a).must_equal [] 57 | end 58 | 59 | specify "returns a 417 if no apps where matched and a expectation header was set" do 60 | app = expectation_cascade do |cascade| 61 | cascade << lambda { |env| [417, {"content-type" => "text/plain"}, []] } 62 | end 63 | env = Rack::MockRequest.env_for('', "HTTP_EXPECT" => "100-continue") 64 | response = app.call(env) 65 | _(response[0]).must_equal 417 66 | _(response[2].to_enum.to_a).must_equal [] 67 | end 68 | 69 | specify "nests expectation cascades" do 70 | app = expectation_cascade do |c1| 71 | c1 << expectation_cascade do |c2| 72 | c2 << lambda { |env| [417, {"content-type" => "text/plain"}, []] } 73 | end 74 | c1 << expectation_cascade do |c2| 75 | c2 << lambda { |env| [200, {"content-type" => "text/plain"}, ["OK"]] } 76 | end 77 | end 78 | env = Rack::MockRequest.env_for 79 | response = app.call(env) 80 | _(response[0]).must_equal 200 81 | _(response[2].to_enum.to_a).must_equal ["OK"] 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/rack/contrib/json_body_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | 5 | module Rack 6 | # A Rack middleware that makes JSON-encoded request bodies available in the 7 | # request.params hash. By default it parses POST, PATCH, and PUT requests 8 | # whose media type is application/json. You can configure it to match 9 | # any verb or media type via the :verbs and :media options. 10 | # 11 | # 12 | # == Examples: 13 | # 14 | # === Parse POST and GET requests only 15 | # use Rack::JSONBodyParser, verbs: ['POST', 'GET'] 16 | # 17 | # === Parse POST|PATCH|PUT requests whose content-type matches 'json' 18 | # use Rack::JSONBodyParser, media: /json/ 19 | # 20 | # === Parse POST requests whose content-type is 'application/json' or 'application/vnd+json' 21 | # use Rack::JSONBodyParser, verbs: ['POST'], media: ['application/json', 'application/vnd.api+json'] 22 | # 23 | class JSONBodyParser 24 | CONTENT_TYPE_MATCHERS = { 25 | String => lambda { |option, header| 26 | Rack::MediaType.type(header) == option 27 | }, 28 | Array => lambda { |options, header| 29 | media_type = Rack::MediaType.type(header) 30 | options.any? { |opt| media_type == opt } 31 | }, 32 | Regexp => lambda { 33 | if //.respond_to?(:match?) 34 | # Use Ruby's fast regex matcher when available 35 | ->(option, header) { option.match? header } 36 | else 37 | # Fall back to the slower matcher for rubies older than 2.4 38 | ->(option, header) { option.match header } 39 | end 40 | }.call(), 41 | }.freeze 42 | 43 | DEFAULT_PARSER = ->(body) { JSON.parse(body, create_additions: false) } 44 | 45 | def initialize( 46 | app, 47 | verbs: %w[POST PATCH PUT], 48 | media: 'application/json', 49 | &block 50 | ) 51 | @app = app 52 | @verbs, @media = verbs, media 53 | @matcher = CONTENT_TYPE_MATCHERS.fetch(@media.class) 54 | @parser = block || DEFAULT_PARSER 55 | end 56 | 57 | def call(env) 58 | begin 59 | if @verbs.include?(env[Rack::REQUEST_METHOD]) && 60 | @matcher.call(@media, env['CONTENT_TYPE']) 61 | 62 | update_form_hash_with_json_body(env) 63 | end 64 | rescue ParserError 65 | body = { error: 'Failed to parse body as JSON' }.to_json 66 | header = { 'content-type' => 'application/json' } 67 | return Rack::Response.new(body, 400, header).finish 68 | end 69 | @app.call(env) 70 | end 71 | 72 | private 73 | 74 | class ParserError < StandardError; end 75 | 76 | def update_form_hash_with_json_body(env) 77 | body = env[Rack::RACK_INPUT] 78 | return unless (body_content = body.read) && !body_content.empty? 79 | 80 | body.rewind if body.respond_to?(:rewind) # somebody might try to read this stream 81 | 82 | begin 83 | parsed_body = @parser.call(body_content) 84 | rescue StandardError 85 | raise ParserError 86 | end 87 | 88 | env.update( 89 | Rack::RACK_REQUEST_FORM_HASH => parsed_body, 90 | Rack::RACK_REQUEST_FORM_INPUT => body 91 | ) 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/spec_rack_csshttprequest.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | 6 | begin 7 | require 'csshttprequest' 8 | require 'rack/contrib/csshttprequest' 9 | 10 | describe "Rack::CSSHTTPRequest" do 11 | def css_httl_request(app) 12 | Rack::Lint.new(Rack::CSSHTTPRequest.new(app)) 13 | end 14 | 15 | before(:each) do 16 | @test_body = '{"bar":"foo"}' 17 | @test_headers = if Rack.release < "3" 18 | {'Content-Type' => 'text/plain'} 19 | else 20 | {'content-type' => 'text/plain'} 21 | end 22 | @encoded_body = CSSHTTPRequest.encode(@test_body) 23 | @app = lambda { |env| [200, @test_headers, [@test_body]] } 24 | end 25 | 26 | specify "env['csshttprequest.chr'] should be set to true when \ 27 | PATH_INFO ends with '.chr'" do 28 | request = Rack::MockRequest.env_for("/blah.chr", :fatal => true) 29 | css_httl_request(@app).call(request) 30 | _(request['csshttprequest.chr']).must_equal true 31 | end 32 | 33 | specify "env['csshttprequest.chr'] should be set to true when \ 34 | request parameter _format == 'chr'" do 35 | request = Rack::MockRequest.env_for("/?_format=chr", :fatal => true) 36 | css_httl_request(@app).call(request) 37 | _(request['csshttprequest.chr']).must_equal true 38 | end 39 | 40 | specify "should not change the headers or response when !env['csshttprequest.chr']" do 41 | request = Rack::MockRequest.env_for("/", :fatal => true) 42 | status, headers, body = css_httl_request(@app).call(request) 43 | _(headers).must_equal @test_headers 44 | _(body.to_enum.to_a.join).must_equal @test_body 45 | end 46 | 47 | describe "when env['csshttprequest.chr']" do 48 | before(:each) do 49 | @request = Rack::MockRequest.env_for("/", 50 | 'csshttprequest.chr' => true, :fatal => true) 51 | end 52 | 53 | specify "should return encoded body" do 54 | body = css_httl_request(@app).call(@request)[2] 55 | _(body.to_enum.to_a.join).must_equal @encoded_body 56 | end 57 | 58 | specify "should modify the content length to the correct value" do 59 | headers = css_httl_request(@app).call(@request)[1] 60 | _(headers['Content-Length']).must_equal @encoded_body.length.to_s 61 | end 62 | 63 | specify "should modify the content type to the correct value" do 64 | headers = css_httl_request(@app).call(@request)[1] 65 | _(headers['Content-Type']).must_equal 'text/css' 66 | end 67 | 68 | specify "should not modify any other headers" do 69 | headers = css_httl_request(@app).call(@request)[1] 70 | if Rack.release < "3" 71 | _(headers).must_equal @test_headers.merge({ 72 | 'Content-Type' => 'text/css', 73 | 'Content-Length' => @encoded_body.length.to_s 74 | }) 75 | else 76 | _(headers).must_equal @test_headers.merge({ 77 | 'content-type' => 'text/css', 78 | 'content-length' => @encoded_body.length.to_s 79 | }) 80 | end 81 | end 82 | end 83 | 84 | end 85 | rescue LoadError => boom 86 | STDERR.puts "WARN: Skipping Rack::CSSHTTPRequest tests (nbio-csshttprequest not installed)" 87 | end 88 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | After a long hiatus, `rack-contrib` is back under active maintenance! 2 | 3 | 4 | # Reporting bugs 5 | 6 | If you think you've found a problem in a `rack-contrib` middleware, please 7 | accept our apologies. Nothing's perfect, although with your help, we can 8 | get closer. 9 | 10 | When reporting a bug, please provide: 11 | 12 | * The version of the `rack-contrib` gem (or git hash of the repo) that you 13 | are running; 14 | 15 | * A tiny `config.ru` which demonstrates how you're using the middleware; 16 | 17 | * An example request which triggers the bug; 18 | 19 | * A description of what you're seeing happen (so that we can tell that we're 20 | seeing the same problem when we reproduce it); and 21 | 22 | * A description of what you expect to see happen. 23 | 24 | Note that, in general, the core maintainers of `rack-contrib` are caretakers 25 | of the codebase, not the fixers-of-bugs. If you wish to see a bug fixed, 26 | you will have a far better time if you submit a pull request (see below) 27 | rather than reporting a bug. 28 | 29 | 30 | # Submitting patches 31 | 32 | New functionality and bug fixes are always welcome. To maintain the quality 33 | of the codebase, however, there are a number of things that all patches must 34 | have before they can be landed: 35 | 36 | * Test cases. A bugfix must have a test case which fails prior to the 37 | bugfix being applied, and which passes afterwards. Feature additions must 38 | have test cases which exercise all features and edge cases. 39 | 40 | * Documentation. Most bugfixes won't require documentation changes 41 | (although some will), but all feature enhancements and new middleware will 42 | *definitely* need to have documentation written. Many existing 43 | middlewares aren't well documented, we know that, but we're trying to 44 | make sure things don't get any *worse* as new things get added. 45 | 46 | * Adhere to existing coding conventions. The existing code isn't in a great 47 | place, but if you diverge from how things are done at the moment the patch 48 | won't get accepted as-is. 49 | 50 | * Support Ruby 2.2 and higher. We maintain the same Ruby version 51 | compatibility as Rack itself. We use [Travis CI test 52 | runs](https://travis-ci.org/rack/rack-contrib) to validate this. 53 | 54 | * Require no external dependencies. Some existing middleware depends on 55 | additional gems in order to function; we feel that this is an 56 | anti-pattern, and so no patches will be accepted which add additional 57 | external gems. 58 | 59 | We will not outright reject patches which do not meet these standards, 60 | however *someone* will have to do the work to bring the patch up to scratch 61 | before it can be landed. 62 | 63 | 64 | # Release frequency 65 | 66 | * Bugfix releases (incrementing `Z` in version `X.Y.Z`), which do not change 67 | documented behaviour in any way, may be released as soon as the bugfix 68 | is landed. 69 | 70 | * Minor releases (incrementing `Y` in version `X.Y.Z`), which change 71 | documented behaviour in ways which are entirely backwards compatible, 72 | should be released each month, in the first few days of the month 73 | (assuming there are any features outstanding). 74 | 75 | * Major releases (incrementing `X` in version `X.Y.Z`), which make changes 76 | to documented behaviour in ways which mean that existing users of the gem 77 | may have to change something about the way they use the gem, should be 78 | released no less than six months apart, and ideally far less often than 79 | that. 80 | -------------------------------------------------------------------------------- /lib/rack/contrib/response_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | require 'rack' 5 | 6 | # Rack::ResponseCache is a Rack middleware that caches responses for successful 7 | # GET requests with no query string to disk or any ruby object that has an 8 | # []= method (so it works with memcached). As with Rails' page caching, this 9 | # middleware only writes to the cache -- it never reads. The logic of whether a 10 | # cached response should be served is left either to your web server, via 11 | # something like the try_files directive in nginx, or to your 12 | # cache-reading middleware of choice, mounted before Rack::ResponseCache in the 13 | # stack. 14 | class Rack::ResponseCache 15 | # The default proc used if a block is not provided to .new 16 | # It unescapes the PATH_INFO of the environment, and makes sure that it doesn't 17 | # include '..'. If the Content-Type of the response is text/(html|css|xml), 18 | # return a path with the appropriate extension (.html, .css, or .xml). 19 | # If the path ends with a / and the Content-Type is text/html, change the basename 20 | # of the path to index.html. 21 | DEFAULT_PATH_PROC = proc do |env, res| 22 | path = Rack::Utils.unescape(env['PATH_INFO']) 23 | headers = res[1] 24 | content_type = headers['Content-Type'] 25 | 26 | if !path.include?('..') and match = /text\/((?:x|ht)ml|css)/o.match(content_type) 27 | type = match[1] 28 | path = "#{path}.#{type}" unless /\.#{type}\z/.match(path) 29 | path = File.join(File.dirname(path), 'index.html') if type == 'html' and File.basename(path) == '.html' 30 | path 31 | end 32 | end 33 | 34 | # Initialize a new ResponseCache object with the given arguments. Arguments: 35 | # * app : The next middleware in the chain. This is always called. 36 | # * cache : The place to cache responses. If a string is provided, a disk 37 | # cache is used, and all cached files will use this directory as the root directory. 38 | # If anything other than a string is provided, it should respond to []=, which will 39 | # be called with a path string and a body value (the 3rd element of the response). 40 | # * &block : If provided, it is called with the environment and the response from the next middleware. 41 | # It should return nil or false if the path should not be cached, and should return 42 | # the pathname to use as a string if the result should be cached. 43 | # If not provided, the DEFAULT_PATH_PROC is used. 44 | def initialize(app, cache, &block) 45 | @app = app 46 | @cache = cache 47 | @path_proc = block || DEFAULT_PATH_PROC 48 | end 49 | 50 | # Call the next middleware with the environment. If the request was successful (response status 200), 51 | # was a GET request, and had an empty query string, call the block set up in initialize to get the path. 52 | # If the cache is a string, create any necessary middle directories, and cache the file in the appropriate 53 | # subdirectory of cache. Otherwise, cache the body of the response as the value with the path as the key. 54 | def call(env) 55 | status, headers, body = @app.call(env) 56 | headers_klass = Rack.release < "3" ? Rack::Utils::HeaderHash : Rack::Headers 57 | headers = headers_klass.new.merge(headers) 58 | 59 | if env['REQUEST_METHOD'] == 'GET' and env['QUERY_STRING'] == '' and status == 200 and path = @path_proc.call(env, [status, headers, body]) 60 | if @cache.is_a?(String) 61 | path = File.join(@cache, path) if @cache 62 | FileUtils.mkdir_p(File.dirname(path)) 63 | File.open(path, 'wb'){|f| body.each{|c| f.write(c)}} 64 | else 65 | @cache[path] = body 66 | end 67 | end 68 | 69 | [status, headers, body] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/spec_rack_mailexceptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | 6 | begin 7 | require './lib/rack/contrib/mailexceptions' 8 | require './test/mail_settings.rb' 9 | 10 | class TestError < RuntimeError 11 | end 12 | 13 | def test_exception 14 | raise TestError, 'Suffering Succotash!' 15 | rescue => boom 16 | return boom 17 | end 18 | 19 | describe 'Rack::MailExceptions' do 20 | def mail_exception(app, options = {}, &block) 21 | middleware = Rack::MailExceptions.new(@app, &block) 22 | if options[:test_mode] 23 | middleware.enable_test_mode 24 | end 25 | Rack::Lint.new(middleware) 26 | end 27 | 28 | before do 29 | @app = lambda { |env| raise TestError, 'Why, I say' } 30 | @env = Rack::MockRequest.env_for("/foo", 31 | 'FOO' => 'BAR', 32 | :method => 'GET', 33 | :input => 'THE BODY' 34 | ) 35 | @smtp_settings = { 36 | :server => 'example.com', 37 | :domain => 'example.com', 38 | :port => 500, 39 | :authentication => :login, 40 | :user_name => 'joe', 41 | :password => 'secret' 42 | } 43 | end 44 | 45 | specify 'yields a configuration object to the block when created' do 46 | called = false 47 | mailer = 48 | mail_exception(@app) do |mail| 49 | called = true 50 | mail.to 'foo@example.org' 51 | mail.from 'bar@example.org' 52 | mail.subject '[ERROR] %s' 53 | mail.smtp @smtp_settings 54 | end 55 | _(called).must_equal(true) 56 | end 57 | 58 | specify 'generates a Mail object with configured settings' do 59 | # Don't use Rack::Lint because we the private method `generate_mail` is tested here 60 | mailer = 61 | Rack::MailExceptions.new(@app) do |mail| 62 | mail.to 'foo@example.org' 63 | mail.from 'bar@example.org' 64 | mail.subject '[ERROR] %s' 65 | mail.smtp @smtp_settings 66 | end 67 | 68 | mail = mailer.send(:generate_mail, test_exception, @env) 69 | _(mail.to).must_equal ['foo@example.org'] 70 | _(mail.from).must_equal ['bar@example.org'] 71 | _(mail.subject).must_equal '[ERROR] Suffering Succotash!' 72 | _(mail.body).wont_equal(nil) 73 | _(mail.body.to_s).must_match(/FOO:\s+"BAR"/) 74 | _(mail.body.to_s).must_match(/^\s*THE BODY\s*$/) 75 | end 76 | 77 | specify 'filters HTTP_EXCEPTION body' do 78 | # Don't use Rack::Lint because we the private method `generate_mail` is tested here 79 | mailer = 80 | Rack::MailExceptions.new(@app) do |mail| 81 | mail.to 'foo@example.org' 82 | mail.from 'bar@example.org' 83 | mail.subject '[ERROR] %s' 84 | mail.smtp @smtp_settings 85 | end 86 | 87 | env = @env.dup 88 | env['HTTP_AUTHORIZATION'] = 'Basic xyzzy12345' 89 | 90 | mail = mailer.send(:generate_mail, test_exception, env) 91 | _(mail.body.to_s).must_match /HTTP_AUTHORIZATION:\s+"Basic \*filtered\*"/ 92 | end 93 | 94 | specify 'catches exceptions raised from app, sends mail, and re-raises' do 95 | mailer = 96 | mail_exception(@app, test_mode: true) do |mail| 97 | mail.to 'foo@example.org' 98 | mail.from 'bar@example.org' 99 | mail.subject '[ERROR] %s' 100 | mail.smtp @smtp_settings 101 | end 102 | _(lambda { mailer.call(@env) }).must_raise(TestError) 103 | _(@env['mail.sent']).must_equal(true) 104 | _(Mail::TestMailer.deliveries.length).must_equal(1) 105 | end 106 | end 107 | rescue LoadError => boom 108 | STDERR.puts "WARN: Skipping Rack::MailExceptions tests (mail not installed)" 109 | end 110 | -------------------------------------------------------------------------------- /test/spec_rack_relative_redirect.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/relative_redirect' 6 | require 'fileutils' 7 | 8 | describe Rack::RelativeRedirect do 9 | def request(opts={}, &block) 10 | @def_status = opts[:status] if opts[:status] 11 | @def_location = opts[:location] if opts[:location] 12 | yield Rack::MockRequest.new(Rack::Lint.new(Rack::RelativeRedirect.new(@def_app, &opts[:block]))).get(opts[:path]||@def_path, opts[:headers]||{}) 13 | end 14 | 15 | before do 16 | @def_path = '/path/to/blah' 17 | @def_status = 301 18 | @def_location = '/redirect/to/blah' 19 | @def_app = lambda { |env| [@def_status, {'Location' => @def_location}, [""]]} 20 | end 21 | 22 | specify "should rewrite Location on all the redirect codes" do 23 | [301, 302, 303, 307, 308].each do |status| 24 | request(:status => status) do |r| 25 | _(r.status).must_equal(status) 26 | _(r.headers['Location']).must_equal('http://example.org/redirect/to/blah') 27 | end 28 | end 29 | end 30 | 31 | specify "should not rewrite Location on other status codes" do 32 | [200, 201, 300, 304, 305, 306, 404, 500].each do |status| 33 | request(:status => status) do |r| 34 | _(r.status).must_equal(status) 35 | _(r.headers['Location']).must_equal('/redirect/to/blah') 36 | end 37 | end 38 | end 39 | 40 | specify "should make the location url an absolute url if currently a relative url" do 41 | request do |r| 42 | _(r.status).must_equal(301) 43 | _(r.headers['Location']).must_equal('http://example.org/redirect/to/blah') 44 | end 45 | request(:status=>302, :location=>'/redirect') do |r| 46 | _(r.status).must_equal(302) 47 | _(r.headers['Location']).must_equal('http://example.org/redirect') 48 | end 49 | end 50 | 51 | specify "should use the request path if the relative url is given and doesn't start with a slash" do 52 | request(:status=>303, :location=>'redirect/to/blah') do |r| 53 | _(r.status).must_equal(303) 54 | _(r.headers['Location']).must_equal('http://example.org/path/to/redirect/to/blah') 55 | end 56 | request(:status=>303, :location=>'redirect') do |r| 57 | _(r.status).must_equal(303) 58 | _(r.headers['Location']).must_equal('http://example.org/path/to/redirect') 59 | end 60 | end 61 | 62 | specify "should use a given block to make the url absolute" do 63 | request(:block=>proc{|env, res| "https://example.org"}) do |r| 64 | _(r.status).must_equal(301) 65 | _(r.headers['Location']).must_equal('https://example.org/redirect/to/blah') 66 | end 67 | request(:status=>303, :location=>'/redirect', :block=>proc{|env, res| "https://e.org:9999/blah"}) do |r| 68 | _(r.status).must_equal(303) 69 | _(r.headers['Location']).must_equal('https://e.org:9999/blah/redirect') 70 | end 71 | end 72 | 73 | specify "should not modify the location url unless the response is a redirect" do 74 | status = 200 75 | @def_app = lambda { |env| [status, {'Content-Type' => "text/html"}, [""]]} 76 | request do |r| 77 | _(r.status).must_equal(200) 78 | _(r.headers).wont_include('Location') 79 | end 80 | status = 404 81 | @def_app = lambda { |env| [status, {'Content-Type' => "text/html", 'Location' => 'redirect'}, [""]]} 82 | request do |r| 83 | _(r.status).must_equal(404) 84 | _(r.headers['Location']).must_equal('redirect') 85 | end 86 | end 87 | 88 | specify "should not modify the location url if it is already an absolute url" do 89 | request(:location=>'https://example.org/') do |r| 90 | _(r.status).must_equal(301) 91 | _(r.headers['Location']).must_equal('https://example.org/') 92 | end 93 | request(:status=>302, :location=>'https://e.org:9999/redirect') do |r| 94 | _(r.status).must_equal(302) 95 | _(r.headers['Location']).must_equal('https://e.org:9999/redirect') 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/spec_rack_deflect.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'timecop' 5 | require 'rack/mock' 6 | require 'rack/contrib/deflect' 7 | 8 | describe "Rack::Deflect" do 9 | 10 | before do 11 | @app = lambda { |env| [200, { 'content-type' => 'text/plain' }, ['cookies']] } 12 | @mock_addr_1 = '111.111.111.111' 13 | @mock_addr_2 = '222.222.222.222' 14 | @mock_addr_3 = '333.333.333.333' 15 | end 16 | 17 | def mock_env remote_addr, path = '/' 18 | Rack::MockRequest.env_for path, { 'REMOTE_ADDR' => remote_addr } 19 | end 20 | 21 | def mock_deflect options = {} 22 | Rack::Lint.new(Rack::Deflect.new(@app, options)) 23 | end 24 | 25 | specify "should allow regular requests to follow through" do 26 | app = mock_deflect 27 | status, headers, body = app.call mock_env(@mock_addr_1) 28 | _(status).must_equal 200 29 | _(body.to_enum.to_a).must_equal ['cookies'] 30 | end 31 | 32 | specify "should deflect requests exceeding the request threshold" do 33 | log = StringIO.new 34 | app = mock_deflect :request_threshold => 5, :interval => 10, :block_duration => 10, :log => log 35 | env = mock_env @mock_addr_1 36 | 37 | # First 5 should be fine 38 | 5.times do 39 | status, headers, body = app.call env 40 | _(status).must_equal 200 41 | if Rack.release < "3" 42 | _(body.to_enum.to_a).must_equal ['cookies'] 43 | else 44 | _(body.to_ary).must_equal ['cookies'] 45 | end 46 | end 47 | 48 | # Remaining requests should fail for 10 seconds 49 | 10.times do 50 | status, headers, body = app.call env 51 | _(status).must_equal 403 52 | _(body.to_enum.to_a).must_equal [] 53 | end 54 | 55 | # Log should reflect that we have blocked an address 56 | _(log.string).must_match(/^deflect\(\d+\/\d+\/\d+\): blocked 111.111.111.111\n/) 57 | end 58 | 59 | specify "should expire blocking" do 60 | log = StringIO.new 61 | app = mock_deflect :request_threshold => 5, :interval => 2, :block_duration => 2, :log => log 62 | env = mock_env @mock_addr_1 63 | 64 | # First 5 should be fine 65 | 5.times do 66 | status, headers, body = app.call env 67 | _(status).must_equal 200 68 | if Rack.release < "3" 69 | _(body.to_enum.to_a).must_equal ['cookies'] 70 | else 71 | _(body.to_ary).must_equal ['cookies'] 72 | end 73 | end 74 | 75 | # Exceeds request threshold 76 | status, headers, body = app.call env 77 | _(status).must_equal 403 78 | _(body.to_enum.to_a).must_equal [] 79 | 80 | # Move to the future so the block will expire 81 | Timecop.travel(Time.now + 3) do 82 | # Another 5 is fine now 83 | 5.times do 84 | status, headers, body = app.call env 85 | _(status).must_equal 200 86 | _(body.to_enum.to_a).must_equal ['cookies'] 87 | end 88 | end 89 | 90 | # Log should reflect block and release 91 | _(log.string).must_match(/deflect.*: blocked 111\.111\.111\.111\ndeflect.*: released 111\.111\.111\.111\n/) 92 | end 93 | 94 | specify "should allow whitelisting of remote addresses" do 95 | app = mock_deflect :whitelist => [@mock_addr_1], :request_threshold => 5, :interval => 2 96 | env = mock_env @mock_addr_1 97 | 98 | # Whitelisted addresses are always fine 99 | 10.times do 100 | status, headers, body = app.call env 101 | _(status).must_equal 200 102 | if Rack.release < "3" 103 | _(body.to_enum.to_a).must_equal ['cookies'] 104 | else 105 | _(body.to_ary).must_equal ['cookies'] 106 | end 107 | end 108 | end 109 | 110 | specify "should allow blacklisting of remote addresses" do 111 | app = mock_deflect :blacklist => [@mock_addr_2] 112 | 113 | status, headers, body = app.call mock_env(@mock_addr_1) 114 | _(status).must_equal 200 115 | _(body.to_enum.to_a).must_equal ['cookies'] 116 | 117 | status, headers, body = app.call mock_env(@mock_addr_2) 118 | _(status).must_equal 403 119 | _(body.to_enum.to_a).must_equal [] 120 | end 121 | 122 | end 123 | -------------------------------------------------------------------------------- /test/spec_rack_lazy_conditional_get.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/lazy_conditional_get' 6 | 7 | $lazy_conditional_get_cache = {} 8 | 9 | describe 'Rack::LazyConditionalGet' do 10 | 11 | def global_last_modified 12 | $lazy_conditional_get_cache[Rack::LazyConditionalGet::KEY] 13 | end 14 | 15 | def set_global_last_modified val 16 | $lazy_conditional_get_cache[Rack::LazyConditionalGet::KEY] = val 17 | end 18 | 19 | let(:core_app) { 20 | lambda { |env| [200, response_headers, ['data']] } 21 | } 22 | 23 | let(:response_headers) { 24 | headers = { 25 | 'content-type' => 'text/plain', 26 | 'rack-lazy-conditional-get' => rack_lazy_conditional_get 27 | } 28 | if response_with_last_modified 29 | headers.merge!({'last-modified' => (Time.now-3600).httpdate}) 30 | end 31 | headers 32 | } 33 | 34 | let(:response_with_last_modified) { false } 35 | 36 | let(:rack_lazy_conditional_get) { 'yes' } 37 | 38 | let(:app) { 39 | Rack::Lint.new(Rack::LazyConditionalGet.new core_app, $lazy_conditional_get_cache) 40 | } 41 | 42 | let(:env) { 43 | Rack::MockRequest.env_for '/', request_headers 44 | } 45 | 46 | let(:request_headers) { 47 | headers = { 'REQUEST_METHOD' => request_method } 48 | if request_with_current_date 49 | headers.merge!({'HTTP_IF_MODIFIED_SINCE' => global_last_modified}) 50 | end 51 | headers 52 | } 53 | 54 | let(:request_method) { 'GET' } 55 | 56 | let (:request_with_current_date) { false } 57 | 58 | before { @myapp = app } 59 | 60 | describe 'When the resource has rack-lazy-conditional-get' do 61 | 62 | it 'Should set right headers' do 63 | status, headers, body = @myapp.call(env) 64 | value(status).must_equal 200 65 | value(headers['rack-lazy-conditional-get']).must_equal 'yes' 66 | value(headers['last-modified']).must_equal global_last_modified 67 | end 68 | 69 | describe 'When the resource already has a last-modified header' do 70 | 71 | let(:response_with_last_modified) { true } 72 | 73 | it 'Does not update last-modified with the global one' do 74 | status, headers, body = @myapp.call(env) 75 | value(status).must_equal 200 76 | value(headers['rack-lazy-conditional-get']).must_equal 'yes' 77 | value(headers['last-modified']).wont_equal global_last_modified 78 | end 79 | 80 | end 81 | 82 | describe 'When loading a resource for the second time' do 83 | 84 | let(:core_app) { lambda { |env| raise } } 85 | let(:request_with_current_date) { true } 86 | 87 | it 'Should not render resource the second time' do 88 | status, headers, body = @myapp.call(env) 89 | value(status).must_equal 304 90 | end 91 | 92 | end 93 | 94 | end 95 | 96 | describe 'When a request is potentially changing data' do 97 | 98 | let(:request_method) { 'POST' } 99 | 100 | it 'Updates the global_last_modified' do 101 | set_global_last_modified (Time.now-3600).httpdate 102 | stamp = global_last_modified 103 | status, headers, body = @myapp.call(env) 104 | value(global_last_modified).wont_equal stamp 105 | end 106 | 107 | describe 'When the skip header is returned' do 108 | 109 | let(:rack_lazy_conditional_get) { 'skip' } 110 | 111 | it 'Does not update the global_last_modified' do 112 | set_global_last_modified (Time.now-3600).httpdate 113 | stamp = global_last_modified 114 | status, headers, body = @myapp.call(env) 115 | value(headers['rack-lazy-conditional-get']).must_equal 'skip' 116 | value(global_last_modified).must_equal stamp 117 | end 118 | 119 | end 120 | 121 | end 122 | 123 | describe 'When the ressource does not have rack-lazy-conditional-get' do 124 | 125 | let(:rack_lazy_conditional_get) { 'no' } 126 | 127 | it 'Should set right headers' do 128 | status, headers, body = @myapp.call(env) 129 | 130 | value(status).must_equal 200 131 | value(headers['rack-lazy-conditional-get']).must_equal 'no' 132 | value(headers['last-modified']).must_be :nil? 133 | end 134 | 135 | end 136 | 137 | end 138 | 139 | -------------------------------------------------------------------------------- /test/spec_rack_simple_endpoint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack' 5 | require 'rack/contrib/simple_endpoint' 6 | 7 | describe "Rack::SimpleEndpoint" do 8 | before do 9 | @app = Proc.new { Rack::Response.new {|r| r.write "Downstream app"}.finish } 10 | end 11 | 12 | def simple_endpoint(app, params, &block) 13 | Rack::Lint.new(Rack::SimpleEndpoint.new(app, params, &block)) 14 | end 15 | 16 | specify "calls downstream app when no match" do 17 | endpoint = simple_endpoint(@app, '/foo') { 'bar' } 18 | status, headers, body = endpoint.call(Rack::MockRequest.env_for('/baz')) 19 | _(status).must_equal 200 20 | _(body.to_enum.to_a).must_equal ['Downstream app'] 21 | end 22 | 23 | specify "calls downstream app when path matches but method does not" do 24 | endpoint = simple_endpoint(@app, '/foo' => :get) { 'bar' } 25 | status, headers, body = endpoint.call(Rack::MockRequest.env_for('/foo', :method => 'post')) 26 | _(status).must_equal 200 27 | _(body.to_enum.to_a).must_equal ['Downstream app'] 28 | end 29 | 30 | specify "calls downstream app when path matches but block returns :pass" do 31 | endpoint = simple_endpoint(@app, '/foo') { :pass } 32 | status, headers, body = endpoint.call(Rack::MockRequest.env_for('/foo')) 33 | _(status).must_equal 200 34 | _(body.to_enum.to_a).must_equal ['Downstream app'] 35 | end 36 | 37 | specify "returns endpoint response when path matches" do 38 | endpoint = simple_endpoint(@app, '/foo') { 'bar' } 39 | status, headers, body = endpoint.call(Rack::MockRequest.env_for('/foo')) 40 | _(status).must_equal 200 41 | _(body.to_enum.to_a).must_equal ['bar'] 42 | end 43 | 44 | specify "returns endpoint response when path and single method requirement match" do 45 | endpoint = simple_endpoint(@app, '/foo' => :get) { 'bar' } 46 | status, headers, body = endpoint.call(Rack::MockRequest.env_for('/foo')) 47 | _(status).must_equal 200 48 | _(body.to_enum.to_a).must_equal ['bar'] 49 | end 50 | 51 | specify "returns endpoint response when path and one of multiple method requirements match" do 52 | endpoint = simple_endpoint(@app, '/foo' => [:get, :post]) { 'bar' } 53 | status, headers, body = endpoint.call(Rack::MockRequest.env_for('/foo', :method => 'post')) 54 | _(status).must_equal 200 55 | _(body.to_enum.to_a).must_equal ['bar'] 56 | end 57 | 58 | specify "returns endpoint response when path matches regex" do 59 | endpoint = simple_endpoint(@app, /foo/) { 'bar' } 60 | status, headers, body = endpoint.call(Rack::MockRequest.env_for('/bar/foo')) 61 | _(status).must_equal 200 62 | _(body.to_enum.to_a).must_equal ['bar'] 63 | end 64 | 65 | specify "block yields Rack::Request and Rack::Response objects" do 66 | endpoint = simple_endpoint(@app, '/foo') do |req, res| 67 | assert_instance_of ::Rack::Request, req 68 | assert_instance_of ::Rack::Response, res 69 | end 70 | endpoint.call(Rack::MockRequest.env_for('/foo')) 71 | end 72 | 73 | specify "block yields MatchData object when Regex path matcher specified" do 74 | endpoint = simple_endpoint(@app, /foo(.+)/) do |req, res, match| 75 | assert_instance_of MatchData, match 76 | assert_equal 'bar', match[1] 77 | end 78 | endpoint.call(Rack::MockRequest.env_for('/foobar')) 79 | end 80 | 81 | specify "block does NOT yield MatchData object when String path matcher specified" do 82 | endpoint = simple_endpoint(@app, '/foo') do |req, res, match| 83 | assert_nil match 84 | end 85 | endpoint.call(Rack::MockRequest.env_for('/foo')) 86 | end 87 | 88 | specify "response honors headers set in block" do 89 | endpoint = simple_endpoint(@app, '/foo') {|req, res| res['X-Foo'] = 'bar'; 'baz' } 90 | status, headers, body = endpoint.call(Rack::MockRequest.env_for('/foo')) 91 | _(status).must_equal 200 92 | _(headers['X-Foo']).must_equal 'bar' 93 | _(body.to_enum.to_a).must_equal ['baz'] 94 | end 95 | 96 | specify "sets Content-Length header" do 97 | endpoint = simple_endpoint(@app, '/foo') {|req, res| 'bar' } 98 | status, headers, body = endpoint.call(Rack::MockRequest.env_for('/foo')) 99 | _(headers['Content-Length']).must_equal '3' 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/rack/contrib/lazy_conditional_get.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | 5 | ## 6 | # This middleware is like Rack::ConditionalGet except that it does 7 | # not have to go down the rack stack and build the resource to check 8 | # the modification date or the ETag. 9 | # 10 | # Instead it makes the assumption that only non-reading requests can 11 | # potentially change the content, meaning any request which is not 12 | # GET or HEAD. Each time you make one of these request, the date is cached 13 | # and any resource is considered identical until the next non-reading request. 14 | # 15 | # Basically you use it this way: 16 | # 17 | # ``` ruby 18 | # use Rack::LazyConditionalGet 19 | # ``` 20 | # 21 | # Although if you have multiple instances, it is better to use something like 22 | # memcached. An argument can be passed to give the cache object. By default 23 | # it is just a Hash. But it can take other objects, including objects which 24 | # respond to `:get` and `:set`. Here is how you would use it with Dalli. 25 | # 26 | # ``` Ruby 27 | # dalli_client = Dalli::Client.new 28 | # use Rack::LazyConditionalGet, dalli_client 29 | # ``` 30 | # 31 | # By default, the middleware only delegates to Rack::ConditionalGet to avoid 32 | # any unwanted behaviour. You have to set a header to any resource which you 33 | # want to be cached. And it will be cached until the next "potential update" 34 | # of your site, that is whenever the next POST/PUT/PATCH/DELETE request 35 | # is received. 36 | # 37 | # The header is `Rack-Lazy-Conditional-Get`. You have to set it to 'yes' 38 | # if you want the middleware to set `Last-Modified` for you. 39 | # 40 | # Bear in mind that if you set `Last-Modified` as well, the middleware will 41 | # not change it. 42 | # 43 | # Regarding the POST/PUT/PATCH/DELETE... requests, they will always reset your 44 | # global modification date. But if you have one of these request and you 45 | # know for sure that it does not modify the cached content, you can set the 46 | # `Rack-Lazy-Conditional-Get` on response to `skip`. This will not update the 47 | # global modification date. 48 | # 49 | # NOTE: This will not work properly in a multi-threaded environment with 50 | # default cache object. A provided cache object should ensure thread-safety 51 | # of the `get`/`set`/`[]`/`[]=` methods. 52 | 53 | class LazyConditionalGet 54 | 55 | KEY = 'global_last_modified'.freeze 56 | READ_METHODS = ['GET','HEAD'] 57 | 58 | def self.new(*) 59 | # This code automatically uses `Rack::ConditionalGet` before 60 | # our middleware. It is equivalent to: 61 | # 62 | # ``` ruby 63 | # use Rack::ConditionalGet 64 | # use Rack::LazyConditionalGet 65 | # ``` 66 | ::Rack::ConditionalGet.new(super) 67 | end 68 | 69 | def initialize app, cache={} 70 | @app = app 71 | @cache = cache 72 | update_cache 73 | end 74 | 75 | def call env 76 | if reading? env and fresh? env 77 | return [304, {'last-modified' => env['HTTP_IF_MODIFIED_SINCE']}, []] 78 | end 79 | 80 | status, headers, body = @app.call env 81 | headers = Rack.release < "3" ? Utils::HeaderHash.new(headers) : headers 82 | 83 | update_cache unless (reading?(env) or skipping?(headers)) 84 | headers['last-modified'] = cached_value if stampable? headers 85 | [status, headers, body] 86 | end 87 | 88 | private 89 | 90 | def fresh? env 91 | env['HTTP_IF_MODIFIED_SINCE'] == cached_value 92 | end 93 | 94 | def reading? env 95 | READ_METHODS.include?(env['REQUEST_METHOD']) 96 | end 97 | 98 | def skipping? headers 99 | headers['rack-lazy-conditional-get'] == 'skip' 100 | end 101 | 102 | def stampable? headers 103 | !headers.has_key?('last-modified') and headers['rack-lazy-conditional-get'] == 'yes' 104 | end 105 | 106 | def update_cache 107 | stamp = Time.now.httpdate 108 | if @cache.respond_to?(:set) 109 | @cache.set(KEY,stamp) 110 | else 111 | @cache[KEY] = stamp 112 | end 113 | end 114 | 115 | def cached_value 116 | @cache.respond_to?(:get) ? @cache.get(KEY) : @cache[KEY] 117 | end 118 | 119 | end 120 | 121 | end 122 | 123 | -------------------------------------------------------------------------------- /lib/rack/contrib/jsonp.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Rack 4 | 5 | # A Rack middleware for providing JSON-P support. 6 | # 7 | # Full credit to Flinn Mueller (http://actsasflinn.com/) for this contribution. 8 | # 9 | class JSONP 10 | include Rack::Utils 11 | 12 | VALID_CALLBACK = /\A[a-zA-Z_$](?:\.?[\w$])*\z/ 13 | 14 | # These hold the Unicode characters \u2028 and \u2029. 15 | # 16 | # They are defined in constants for Ruby 1.8 compatibility. 17 | # 18 | # In 1.8 19 | # "\u2028" # => "u2028" 20 | # "\u2029" # => "u2029" 21 | # In 1.9 22 | # "\342\200\250" # => "\u2028" 23 | # "\342\200\251" # => "\u2029" 24 | U2028, U2029 = ("\u2028" == 'u2028') ? ["\342\200\250", "\342\200\251"] : ["\u2028", "\u2029"] 25 | 26 | HEADERS_KLASS = Rack.release < "3" ? Utils::HeaderHash : Headers 27 | private_constant :HEADERS_KLASS 28 | 29 | def initialize(app) 30 | @app = app 31 | end 32 | 33 | # Proxies the request to the application, stripping out the JSON-P callback 34 | # method and padding the response with the appropriate callback format if 35 | # the returned body is application/json 36 | # 37 | # Changes nothing if no callback param is specified. 38 | # 39 | def call(env) 40 | request = Rack::Request.new(env) 41 | 42 | status, headers, response = @app.call(env) 43 | 44 | if STATUS_WITH_NO_ENTITY_BODY.include?(status) 45 | return status, headers, response 46 | end 47 | 48 | headers = HEADERS_KLASS.new.merge(headers) 49 | 50 | if is_json?(headers) && has_callback?(request) 51 | callback = request.params['callback'] 52 | return bad_request unless valid_callback?(callback) 53 | 54 | response = pad(callback, response) 55 | 56 | # No longer json, its javascript! 57 | headers['Content-Type'] = headers['Content-Type'].gsub('json', 'javascript') 58 | 59 | # Set new Content-Length, if it was set before we mutated the response body 60 | if headers['Content-Length'] 61 | length = response.map(&:bytesize).reduce(0, :+) 62 | headers['Content-Length'] = length.to_s 63 | end 64 | end 65 | 66 | [status, headers, response] 67 | end 68 | 69 | private 70 | 71 | def is_json?(headers) 72 | headers.key?('Content-Type') && headers['Content-Type'].include?('application/json') 73 | end 74 | 75 | def has_callback?(request) 76 | request.params.include?('callback') and not request.params['callback'].to_s.empty? 77 | end 78 | 79 | # See: 80 | # http://stackoverflow.com/questions/1661197/valid-characters-for-javascript-variable-names 81 | # 82 | # NOTE: Supports dots (.) since callbacks are often in objects: 83 | # 84 | def valid_callback?(callback) 85 | callback =~ VALID_CALLBACK 86 | end 87 | 88 | # Pads the response with the appropriate callback format according to the 89 | # JSON-P spec/requirements. 90 | # 91 | # The Rack response spec indicates that it should be enumerable. The 92 | # method of combining all of the data into a single string makes sense 93 | # since JSON is returned as a full string. 94 | # 95 | def pad(callback, response) 96 | body = response.to_enum.map do |s| 97 | # U+2028 and U+2029 are allowed inside strings in JSON (as all literal 98 | # Unicode characters) but JavaScript defines them as newline 99 | # seperators. Because no literal newlines are allowed in a string, this 100 | # causes a ParseError in the browser. We work around this issue by 101 | # replacing them with the escaped version. This should be safe because 102 | # according to the JSON spec, these characters are *only* valid inside 103 | # a string and should therefore not be present any other places. 104 | s.gsub(U2028, '\u2028').gsub(U2029, '\u2029') 105 | end.join 106 | 107 | # https://github.com/rack/rack-contrib/issues/46 108 | response.close if response.respond_to?(:close) 109 | 110 | ["/**/#{callback}(#{body})"] 111 | end 112 | 113 | def bad_request(body = "Bad Request") 114 | [ 400, { 'content-type' => 'text/plain', 'content-length' => body.bytesize.to_s }, [body] ] 115 | end 116 | 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/rack/contrib/mailexceptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'net/smtp' 4 | require 'mail' 5 | require 'erb' 6 | 7 | module Rack 8 | # Catches all exceptions raised from the app it wraps and 9 | # sends a useful email with the exception, stacktrace, and 10 | # contents of the environment. 11 | 12 | # use smtp 13 | # use Rack::MailExceptions do |mail| 14 | # mail.to 'test@gmail.com' 15 | # mail.smtp :address => 'mail.test.com', :user_name => 'test@test.com', :password => 'test' 16 | # end 17 | # use sendmail 18 | # use Rack::MailExceptions do |mail| 19 | # mail.to 'test@gmail.com' 20 | # mail.smtp false 21 | # end 22 | 23 | class MailExceptions 24 | attr_reader :config 25 | 26 | def initialize(app) 27 | @app = app 28 | @config = { 29 | :to => nil, 30 | :from => ENV['USER'] || 'rack@localhost', 31 | :subject => '[exception] %s', 32 | :smtp => { 33 | :address => 'localhost', 34 | :domain => 'localhost', 35 | :port => 25, 36 | :authentication => :login, 37 | :user_name => nil, 38 | :password => nil 39 | } 40 | } 41 | @template = ERB.new(TEMPLATE) 42 | @test_mode = false 43 | yield self if block_given? 44 | end 45 | 46 | def call(env) 47 | status, headers, body = 48 | begin 49 | @app.call(env) 50 | rescue => boom 51 | send_notification boom, env 52 | raise 53 | end 54 | send_notification env['mail.exception'], env if env['mail.exception'] 55 | [status, headers, body] 56 | end 57 | 58 | %w[to from subject].each do |meth| 59 | define_method(meth) { |value| @config[meth.to_sym] = value } 60 | end 61 | 62 | def smtp(settings={}) 63 | if settings 64 | @config[:smtp].merge! settings 65 | else 66 | @config[:smtp] = nil 67 | end 68 | end 69 | 70 | def enable_test_mode 71 | @test_mode = true 72 | end 73 | 74 | def disable_test_mode 75 | @test_mode = false 76 | end 77 | 78 | private 79 | def generate_mail(exception, env) 80 | Mail.new({ 81 | :from => config[:from], 82 | :to => config[:to], 83 | :subject => config[:subject] % [exception.to_s], 84 | :body => @template.result(binding), 85 | :charset => "UTF-8" 86 | }) 87 | end 88 | 89 | def send_notification(exception, env) 90 | mail = generate_mail(exception, env) 91 | if @test_mode 92 | mail.delivery_method :test 93 | elsif config[:smtp] 94 | smtp = config[:smtp] 95 | # for backward compatibility, replace the :server key with :address 96 | address = smtp.delete :server 97 | smtp[:address] = address if address 98 | mail.delivery_method :smtp, smtp 99 | else 100 | mail.delivery_method :sendmail 101 | end 102 | mail.deliver! 103 | env['mail.sent'] = true 104 | mail 105 | end 106 | 107 | def extract_body(env) 108 | if io = env['rack.input'] 109 | io.rewind if io.respond_to?(:rewind) 110 | io.read 111 | end 112 | end 113 | 114 | TEMPLATE = (<<-'EMAIL').gsub(/^ {4}/, '') 115 | A <%= exception.class.to_s %> occured: <%= exception.to_s %> 116 | <% if body = extract_body(env) %> 117 | 118 | =================================================================== 119 | Request Body: 120 | =================================================================== 121 | 122 | <%= body.gsub(/^/, ' ') %> 123 | <% end %> 124 | 125 | =================================================================== 126 | Rack Environment: 127 | =================================================================== 128 | 129 | PID: <%= $$ %> 130 | PWD: <%= Dir.getwd %> 131 | 132 | <%= env.to_a. 133 | sort{|a,b| a.first <=> b.first}. 134 | map do |k,v| 135 | if k == 'HTTP_AUTHORIZATION' and v =~ /^Basic / 136 | v = 'Basic *filtered*' 137 | end 138 | "%-25s%p" % [k+':', v] 139 | end. 140 | join("\n ") %> 141 | 142 | <% if exception.respond_to?(:backtrace) %> 143 | =================================================================== 144 | Backtrace: 145 | =================================================================== 146 | 147 | <%= exception.backtrace.join("\n ") %> 148 | <% end %> 149 | EMAIL 150 | 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /lib/rack/contrib/profiler.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ruby-prof' 4 | 5 | module Rack 6 | # Set the profile=process_time query parameter to download a 7 | # calltree profile of the request. 8 | # 9 | # Pass the :printer option to pick a different result format. Note that 10 | # some printers (such as CallTreePrinter) have broken the 11 | # `AbstractPrinter` API, and thus will not work. Bug reports to 12 | # `ruby-prof`, please, not us. 13 | # 14 | # You can cause every request to be run multiple times by passing the 15 | # `:times` option to the `use Rack::Profiler` call. You can also run a 16 | # given request multiple times, by setting the `profiler_runs` query 17 | # parameter in the request URL. 18 | # 19 | class Profiler 20 | MODES = %w(process_time wall_time cpu_time 21 | allocations memory gc_runs gc_time) 22 | 23 | DEFAULT_PRINTER = :call_stack 24 | 25 | CONTENT_TYPES = Hash.new('application/octet-stream').merge( 26 | 'RubyProf::FlatPrinter' => 'text/plain', 27 | 'RubyProf::GraphPrinter' => 'text/plain', 28 | 'RubyProf::GraphHtmlPrinter' => 'text/html', 29 | 'RubyProf::CallStackPrinter' => 'text/html') 30 | 31 | # Accepts a :printer => [:call_stack|:call_tree|:graph_html|:graph|:flat] 32 | # option defaulting to :call_stack. 33 | def initialize(app, options = {}) 34 | @app = app 35 | @profile = nil 36 | @printer = parse_printer(options[:printer] || DEFAULT_PRINTER) 37 | @times = (options[:times] || 1).to_i 38 | @maximum_runs = options.fetch(:maximum_runs, 10) 39 | end 40 | 41 | attr :maximum_runs 42 | 43 | def call(env) 44 | if mode = profiling?(env) 45 | profile(env, mode) 46 | else 47 | @app.call(env) 48 | end 49 | end 50 | 51 | private 52 | def profiling?(env) 53 | return if @profile && @profile.running? 54 | 55 | request = Rack::Request.new(env.clone) 56 | if mode = request.params.delete('profile') 57 | if ::RubyProf.const_defined?(mode.upcase) 58 | mode 59 | else 60 | env['rack.errors'].write "Invalid RubyProf measure_mode: " + 61 | "#{mode}. Use one of #{MODES.to_a.join(', ')}" 62 | false 63 | end 64 | end 65 | end 66 | 67 | # How many times to run the request within the profiler. 68 | # If the profiler_runs query parameter is set, use that. 69 | # Otherwise, use the :times option passed to `#initialize`. 70 | # If the profiler_runs query parameter is greater than the 71 | # :maximum option passed to `#initialize`, use the :maximum 72 | # option. 73 | def runs(request) 74 | if profiler_runs = request.params['profiler_runs'] 75 | profiler_runs = profiler_runs.to_i 76 | if profiler_runs > @maximum_runs 77 | return @maximum_runs 78 | else 79 | return profiler_runs 80 | end 81 | else 82 | return @times 83 | end 84 | end 85 | 86 | def profile(env, mode) 87 | @profile = ::RubyProf::Profile.new(measure_mode: ::RubyProf.const_get(mode.upcase)) 88 | 89 | GC.enable_stats if GC.respond_to?(:enable_stats) 90 | request = Rack::Request.new(env.clone) 91 | result = @profile.profile do 92 | runs(request).times { @app.call(env) } 93 | end 94 | GC.disable_stats if GC.respond_to?(:disable_stats) 95 | 96 | [200, headers(@printer, env, mode), print(@printer, result)] 97 | end 98 | 99 | def print(printer, result) 100 | body = StringIO.new 101 | printer.new(result).print(body, :min_percent => 0.01) 102 | body.rewind 103 | body 104 | end 105 | 106 | def headers(printer, env, mode) 107 | headers = { 'content-type' => CONTENT_TYPES[printer.name] } 108 | if printer == ::RubyProf::CallTreePrinter 109 | filename = ::File.basename(env['PATH_INFO']) 110 | headers['content-disposition'] = 111 | %(attachment; filename="#{filename}.#{mode}.tree") 112 | end 113 | headers 114 | end 115 | 116 | def parse_printer(printer) 117 | if printer.is_a?(Class) 118 | printer 119 | else 120 | name = "#{camel_case(printer)}Printer" 121 | if ::RubyProf.const_defined?(name) 122 | ::RubyProf.const_get(name) 123 | else 124 | ::RubyProf::FlatPrinter 125 | end 126 | end 127 | end 128 | 129 | def camel_case(word) 130 | word.to_s.gsub(/(?:^|_)(.)/) { $1.upcase } 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /test/spec_rack_locale.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'minitest/hooks' 5 | require 'rack/mock' 6 | 7 | begin 8 | require './lib/rack/contrib/locale' 9 | 10 | describe "Rack::Locale" do 11 | include Minitest::Hooks 12 | 13 | before do 14 | # Set the locales that will be used at various points in the tests 15 | I18n.config.available_locales = [:en, :dk, :'en-gb', :es, :zh] 16 | I18n.default_locale = :en 17 | end 18 | 19 | def app 20 | @app ||= Rack::Lint.new( 21 | Rack::Builder.new do 22 | use Rack::Locale 23 | run lambda { |env| [ 200, {}, [ I18n.locale.to_s ] ] } 24 | end 25 | ) 26 | end 27 | 28 | def response_with_languages(accept_languages) 29 | if accept_languages 30 | Rack::MockRequest.new(app).get('/', { 'HTTP_ACCEPT_LANGUAGE' => accept_languages } ) 31 | else 32 | Rack::MockRequest.new(app).get('/', {}) 33 | end 34 | end 35 | 36 | def enforce_available_locales(enforce) 37 | default_enforce = I18n.enforce_available_locales 38 | I18n.enforce_available_locales = enforce 39 | yield 40 | ensure 41 | I18n.enforce_available_locales = default_enforce 42 | end 43 | 44 | specify 'should use I18n.default_locale if no languages are requested' do 45 | I18n.default_locale = :zh 46 | _(response_with_languages(nil).body).must_equal('zh') 47 | end 48 | 49 | specify 'should treat an empty qvalue as 1.0' do 50 | _(response_with_languages('en,es;q=0.95').body).must_equal('en') 51 | end 52 | 53 | specify 'should set the Content-Language response header' do 54 | headers = response_with_languages('de;q=0.7,dk;q=0.9').headers 55 | _(headers['Content-Language']).must_equal('dk') 56 | end 57 | 58 | specify 'should pick the language with the highest qvalue' do 59 | _(response_with_languages('en;q=0.9,es;q=0.95').body).must_equal('es') 60 | end 61 | 62 | specify 'should ignore spaces between comma separated field values' do 63 | _(response_with_languages('en;q=0.9, es;q=0.95').body).must_equal('es') 64 | end 65 | 66 | specify 'should ignore spaces within quality value' do 67 | _(response_with_languages('en; q=0.9,es; q=0.95').body).must_equal('es') 68 | end 69 | 70 | specify 'should retain full language codes' do 71 | _(response_with_languages('en-gb,en-us;q=0.95;en').body).must_equal('en-gb') 72 | end 73 | 74 | specify 'should match languages with variants' do 75 | _(response_with_languages('pt;Q=0.9,es-CL').body).must_equal('es') 76 | end 77 | 78 | specify 'should match languages with variants case insensitively' do 79 | _(response_with_languages('pt;Q=0.9,ES-CL').body).must_equal('es') 80 | end 81 | 82 | specify 'should skip * if it is followed by other languages' do 83 | _(response_with_languages('*,dk;q=0.5').body).must_equal('dk') 84 | end 85 | 86 | specify 'should use default locale if there is only *' do 87 | _(response_with_languages('*').body).must_equal('en') 88 | end 89 | 90 | specify 'should ignore languages with q=0' do 91 | _(response_with_languages('dk;q=0').body).must_equal(I18n.default_locale.to_s) 92 | end 93 | 94 | specify 'should handle Q=' do 95 | _(response_with_languages('en;Q=0.9,es;Q=0.95').body).must_equal('es') 96 | end 97 | 98 | specify 'should reset the I18n locale after the response' do 99 | I18n.locale = :es 100 | response_with_languages('en,de;q=0.8') 101 | _(I18n.locale).must_equal(:es) 102 | end 103 | 104 | specify 'should pick the available language' do 105 | enforce_available_locales(true) do 106 | _(response_with_languages('ch,en;q=0.9,es;q=0.95').body).must_equal('es') 107 | end 108 | end 109 | 110 | specify 'should match languages case insensitively' do 111 | enforce_available_locales(true) do 112 | _(response_with_languages('EN;q=0.9,ES;q=0.95').body).must_equal('es') 113 | end 114 | end 115 | 116 | specify 'should use default_locale if there is no matching language while enforcing available_locales' do 117 | I18n.default_locale = :zh 118 | enforce_available_locales(true) do 119 | _(response_with_languages('ja').body).must_equal('zh') 120 | end 121 | end 122 | 123 | specify 'when not enforce should pick the language with the highest qvalue' do 124 | enforce_available_locales(false) do 125 | _(response_with_languages('ch,en;q=0.9').body).must_equal('ch') 126 | end 127 | end 128 | end 129 | rescue LoadError 130 | STDERR.puts "WARN: Skipping Rack::Locale tests (i18n not installed)" 131 | end 132 | -------------------------------------------------------------------------------- /lib/rack/contrib/deflect.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'thread' 4 | 5 | # TODO: optional stats 6 | # TODO: performance 7 | # TODO: clean up tests 8 | 9 | module Rack 10 | 11 | ## 12 | # Rack middleware for protecting against Denial-of-service attacks 13 | # http://en.wikipedia.org/wiki/Denial-of-service_attack. 14 | # 15 | # This middleware is designed for small deployments, which most likely 16 | # are not utilizing load balancing from other software or hardware. Deflect 17 | # current supports the following functionality: 18 | # 19 | # * Saturation prevention (small DoS attacks, or request abuse) 20 | # * Blacklisting of remote addresses 21 | # * Whitelisting of remote addresses 22 | # * Logging 23 | # 24 | # === Options: 25 | # 26 | # :log When false logging will be bypassed, otherwise pass an object responding to #puts 27 | # :log_format Alter the logging format 28 | # :log_date_format Alter the logging date format 29 | # :request_threshold Number of requests allowed within the set :interval. Defaults to 100 30 | # :interval Duration in seconds until the request counter is reset. Defaults to 5 31 | # :block_duration Duration in seconds that a remote address will be blocked. Defaults to 900 (15 minutes) 32 | # :whitelist Array of remote addresses which bypass Deflect. NOTE: this does not block others 33 | # :blacklist Array of remote addresses immediately considered malicious 34 | # 35 | # === Examples: 36 | # 37 | # use Rack::Deflect, :log => $stdout, :request_threshold => 20, :interval => 2, :block_duration => 60 38 | # 39 | # CREDIT: TJ Holowaychuk 40 | # 41 | 42 | class Deflect 43 | 44 | attr_reader :options 45 | 46 | def initialize app, options = {} 47 | @mutex = Mutex.new 48 | @remote_addr_map = {} 49 | @app, @options = app, { 50 | :log => false, 51 | :log_format => 'deflect(%s): %s', 52 | :log_date_format => '%m/%d/%Y', 53 | :request_threshold => 100, 54 | :interval => 5, 55 | :block_duration => 900, 56 | :whitelist => [], 57 | :blacklist => [] 58 | }.merge(options) 59 | end 60 | 61 | def call env 62 | return deflect! if deflect? env 63 | status, headers, body = @app.call env 64 | [status, headers, body] 65 | end 66 | 67 | def deflect! 68 | [403, { 'content-type' => 'text/html', 'content-length' => '0' }, []] 69 | end 70 | 71 | def deflect? env 72 | remote_addr = env['REMOTE_ADDR'] 73 | return false if options[:whitelist].include? remote_addr 74 | return true if options[:blacklist].include? remote_addr 75 | sync { watch(remote_addr) } 76 | end 77 | 78 | def log message 79 | return unless options[:log] 80 | options[:log].puts(options[:log_format] % [Time.now.strftime(options[:log_date_format]), message]) 81 | end 82 | 83 | def sync &block 84 | @mutex.synchronize(&block) 85 | end 86 | 87 | def map(remote_addr) 88 | @remote_addr_map[remote_addr] ||= { 89 | :expires => Time.now + options[:interval], 90 | :requests => 0 91 | } 92 | end 93 | 94 | def watch(remote_addr) 95 | increment_requests(remote_addr) 96 | clear!(remote_addr) if watch_expired?(remote_addr) and not blocked?(remote_addr) 97 | clear!(remote_addr) if blocked?(remote_addr) and block_expired?(remote_addr) 98 | block!(remote_addr) if watching?(remote_addr) and exceeded_request_threshold?(remote_addr) 99 | blocked?(remote_addr) 100 | end 101 | 102 | def block!(remote_addr) 103 | return if blocked?(remote_addr) 104 | log "blocked #{remote_addr}" 105 | map(remote_addr)[:block_expires] = Time.now + options[:block_duration] 106 | end 107 | 108 | def blocked?(remote_addr) 109 | map(remote_addr).has_key? :block_expires 110 | end 111 | 112 | def block_expired?(remote_addr) 113 | map(remote_addr)[:block_expires] < Time.now rescue false 114 | end 115 | 116 | def watching?(remote_addr) 117 | @remote_addr_map.has_key? remote_addr 118 | end 119 | 120 | def clear!(remote_addr) 121 | return unless watching?(remote_addr) 122 | log "released #{remote_addr}" if blocked?(remote_addr) 123 | @remote_addr_map.delete remote_addr 124 | end 125 | 126 | def increment_requests(remote_addr) 127 | map(remote_addr)[:requests] += 1 128 | end 129 | 130 | def exceeded_request_threshold?(remote_addr) 131 | map(remote_addr)[:requests] > options[:request_threshold] 132 | end 133 | 134 | def watch_expired?(remote_addr) 135 | map(remote_addr)[:expires] <= Time.now 136 | end 137 | 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /test/spec_rack_common_cookies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/builder' 6 | require 'rack/contrib/common_cookies' 7 | 8 | describe Rack::CommonCookies do 9 | 10 | before do 11 | @app = Rack::Lint.new( 12 | Rack::Builder.new do 13 | use Rack::CommonCookies 14 | run lambda {|env| [200, {'Set-Cookie' => env['HTTP_COOKIE']}, []] } 15 | end 16 | ) 17 | end 18 | 19 | def request 20 | Rack::MockRequest.new(@app) 21 | end 22 | 23 | def make_request(domain, cookies='key=value') 24 | request.get '/', 'HTTP_COOKIE' => cookies, 'HTTP_HOST' => domain 25 | end 26 | 27 | specify 'should use .domain.com for cookies from domain.com' do 28 | response = make_request 'domain.com' 29 | _(response.headers['Set-Cookie']).must_equal 'key=value; domain=.domain.com' 30 | end 31 | 32 | specify 'should use .domain.com for cookies from www.domain.com' do 33 | response = make_request 'www.domain.com' 34 | _(response.headers['Set-Cookie']).must_equal 'key=value; domain=.domain.com' 35 | end 36 | 37 | specify 'should use .domain.com for cookies from subdomain.domain.com' do 38 | response = make_request 'subdomain.domain.com' 39 | _(response.headers['Set-Cookie']).must_equal 'key=value; domain=.domain.com' 40 | end 41 | 42 | specify 'should use .domain.com for cookies from 0.subdomain1.subdomain2.domain.com' do 43 | response = make_request '0.subdomain1.subdomain2.domain.com' 44 | _(response.headers['Set-Cookie']).must_equal 'key=value; domain=.domain.com' 45 | end 46 | 47 | specify 'should use .domain.local for cookies from domain.local' do 48 | response = make_request '0.subdomain1.subdomain2.domain.com' 49 | _(response.headers['Set-Cookie']).must_equal 'key=value; domain=.domain.com' 50 | end 51 | 52 | specify 'should use .domain.local for cookies from subdomain.domain.local' do 53 | response = make_request 'subdomain.domain.local' 54 | _(response.headers['Set-Cookie']).must_equal 'key=value; domain=.domain.local' 55 | end 56 | 57 | specify 'should use .domain.com.ua for cookies from domain.com.ua' do 58 | response = make_request 'domain.com.ua' 59 | _(response.headers['Set-Cookie']).must_equal 'key=value; domain=.domain.com.ua' 60 | end 61 | 62 | specify 'should use .domain.com.ua for cookies from subdomain.domain.com.ua' do 63 | response = make_request 'subdomain.domain.com.ua' 64 | _(response.headers['Set-Cookie']).must_equal 'key=value; domain=.domain.com.ua' 65 | end 66 | 67 | specify 'should use .domain.co.uk for cookies from domain.co.uk' do 68 | response = make_request 'domain.co.uk' 69 | _(response.headers['Set-Cookie']).must_equal 'key=value; domain=.domain.co.uk' 70 | end 71 | 72 | specify 'should use .domain.co.uk for cookies from subdomain.domain.co.uk' do 73 | response = make_request 'subdomain.domain.co.uk' 74 | _(response.headers['Set-Cookie']).must_equal 'key=value; domain=.domain.co.uk' 75 | end 76 | 77 | specify 'should use .domain.eu.com for cookies from domain.eu.com' do 78 | response = make_request 'domain.eu.com' 79 | _(response.headers['Set-Cookie']).must_equal 'key=value; domain=.domain.eu.com' 80 | end 81 | 82 | specify 'should use .domain.eu.com for cookies from subdomain.domain.eu.com' do 83 | response = make_request 'subdomain.domain.eu.com' 84 | _(response.headers['Set-Cookie']).must_equal 'key=value; domain=.domain.eu.com' 85 | end 86 | 87 | specify 'should work with multiple cookies' do 88 | response = make_request 'sub.domain.bz', "key=value\nkey1=value2" 89 | _(response.headers['Set-Cookie']).must_equal "key=value; domain=.domain.bz\nkey1=value2; domain=.domain.bz" 90 | end if Rack.release < "3" 91 | 92 | specify 'should not work with multiple cookies' do 93 | error = _ { make_request 'sub.domain.bz', "key=value\nkey1=value2" }.must_raise(Rack::Lint::LintError) 94 | _(error.message).must_match(/invalid header value/) 95 | end if Rack.release > "3" 96 | 97 | specify 'should work with cookies which have explicit domain' do 98 | response = make_request 'sub.domain.bz', "key=value; domain=domain.bz" 99 | _(response.headers['Set-Cookie']).must_equal "key=value; domain=.domain.bz" 100 | end 101 | 102 | specify 'should not touch cookies if domain is localhost' do 103 | response = make_request 'localhost' 104 | _(response.headers['Set-Cookie']).must_equal "key=value" 105 | end 106 | 107 | specify 'should not touch cookies if domain is ip address' do 108 | response = make_request '127.0.0.1' 109 | _(response.headers['Set-Cookie']).must_equal "key=value" 110 | end 111 | 112 | specify 'should use .domain.com for cookies from subdomain.domain.com:3000' do 113 | response = make_request 'subdomain.domain.com:3000' 114 | _(response.headers['Set-Cookie']).must_equal "key=value; domain=.domain.com" 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/rack/contrib/static_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'time' 4 | 5 | module Rack 6 | 7 | # 8 | # The Rack::StaticCache middleware automatically adds, removes and modifies 9 | # stuffs in response headers to facilitiate client and proxy caching for static files 10 | # that minimizes http requests and improves overall load times for second time visitors. 11 | # 12 | # Once a static content is stored in a client/proxy the only way to enforce the browser 13 | # to fetch the latest content and ignore the cache is to rename the static file. 14 | # 15 | # Alternatively, we can add a version number into the URL to the content to bypass 16 | # the caches. Rack::StaticCache by default handles version numbers in the filename. 17 | # As an example, 18 | # http://yoursite.com/images/test-1.0.0.png and http://yoursite.com/images/test-2.0.0.png 19 | # both reffers to the same image file http://yoursite.com/images/test.png 20 | # 21 | # Another way to bypass the cache is adding the version number in a field-value pair in the 22 | # URL query string. As an example, http://yoursite.com/images/test.png?v=1.0.0 23 | # In that case, set the option :versioning to false to avoid unnecessary regexp calculations. 24 | # 25 | # It's better to keep the current version number in some config file and use it in every static 26 | # content's URL. So each time we modify our static contents, we just have to change the version 27 | # number to enforce the browser to fetch the latest content. 28 | # 29 | # You can use Rack::Deflater along with Rack::StaticCache for further improvements in page loading time. 30 | # 31 | # If you'd like to use a non-standard version identifier in your URLs, you 32 | # can set the regex to remove with the `:version_regex` option. If you 33 | # want to capture something after the regex (such as file extensions), you 34 | # should capture that as `\1`. All other captured subexpressions will be 35 | # discarded. You may find the `?:` capture modifier helpful. 36 | # 37 | # Examples: 38 | # use Rack::StaticCache, :urls => ["/images", "/css", "/js", "/documents*"], :root => "statics" 39 | # will serve all requests beginning with /images, /css or /js from the 40 | # directory "statics/images", "statics/css", "statics/js". 41 | # All the files from these directories will have modified headers to enable client/proxy caching, 42 | # except the files from the directory "documents". Append a * (star) at the end of the pattern 43 | # if you want to disable caching for any pattern . In that case, plain static contents will be served with 44 | # default headers. 45 | # 46 | # use Rack::StaticCache, :urls => ["/images"], :duration => 2, :versioning => false 47 | # will serve all requests beginning with /images under the current directory (default for the option :root 48 | # is current directory). All the contents served will have cache expiration duration set to 2 years in headers 49 | # (default for :duration is 1 year), and StaticCache will not compute any versioning logics (default for 50 | # :versioning is true) 51 | # 52 | 53 | 54 | class StaticCache 55 | HEADERS_KLASS = Rack.release < "3" ? Utils::HeaderHash : Headers 56 | private_constant :HEADERS_KLASS 57 | 58 | def initialize(app, options={}) 59 | @app = app 60 | @urls = options[:urls] 61 | @no_cache = {} 62 | @urls.collect! do |url| 63 | if url =~ /\*$/ 64 | url_prefix = url.sub(/\*$/, '') 65 | @no_cache[url_prefix] = 1 66 | url_prefix 67 | else 68 | url 69 | end 70 | end 71 | root = options[:root] || Dir.pwd 72 | @file_server = Rack::Files.new(root) 73 | @cache_duration = options[:duration] || 1 74 | @versioning_enabled = options.fetch(:versioning, true) 75 | if @versioning_enabled 76 | @version_regex = options.fetch(:version_regex, /-[\d.]+([.][a-zA-Z][\w]+)?$/) 77 | end 78 | @duration_in_seconds = self.duration_in_seconds 79 | end 80 | 81 | def call(env) 82 | path = env["PATH_INFO"] 83 | url = @urls.detect{ |u| path.index(u) == 0 } 84 | if url.nil? 85 | @app.call(env) 86 | else 87 | if @versioning_enabled 88 | path.sub!(@version_regex, '\1') 89 | end 90 | 91 | status, headers, body = @file_server.call(env) 92 | headers = HEADERS_KLASS.new.merge(headers) 93 | 94 | if @no_cache[url].nil? 95 | headers['Cache-Control'] ="max-age=#{@duration_in_seconds}, public" 96 | headers['Expires'] = duration_in_words 97 | end 98 | headers['Date'] = Time.now.httpdate 99 | [status, headers, body] 100 | end 101 | end 102 | 103 | def duration_in_words 104 | (Time.now.utc + self.duration_in_seconds).httpdate 105 | end 106 | 107 | def duration_in_seconds 108 | (60 * 60 * 24 * 365 * @cache_duration).to_i 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/spec_rack_json_body_parser_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/json_body_parser' 6 | 7 | describe Rack::JSONBodyParser do 8 | 9 | def app 10 | ->(env) { [200, {}, [Rack::Request.new(env).params.to_s]] } 11 | end 12 | 13 | def mock_env(input: '{"key": "value"}', type: 'application/json') 14 | headers = if type 15 | { input: input, 'REQUEST_METHOD' => 'POST', 'CONTENT_TYPE' => type } 16 | else 17 | { input: input, 'REQUEST_METHOD' => 'POST' } 18 | end 19 | 20 | Rack::MockRequest.env_for '/', headers 21 | end 22 | 23 | def create_parser(app, **args, &block) 24 | Rack::Lint.new(Rack::JSONBodyParser.new(app, **args, &block)) 25 | end 26 | 27 | specify "should parse 'application/json' requests" do 28 | res = create_parser(app).call(mock_env) 29 | _(res[2].to_enum.to_a.join).must_equal '{"key"=>"value"}' 30 | end 31 | 32 | specify "should parse 'application/json; charset=utf-8' requests" do 33 | env = mock_env(type: 'application/json; charset=utf-8') 34 | res = create_parser(app).call(env) 35 | _(res[2].to_enum.to_a.join).must_equal '{"key"=>"value"}' 36 | end 37 | 38 | specify "should parse 'application/json' requests with an empty body" do 39 | res = create_parser(app).call(mock_env(input: '')) 40 | _(res[2].to_enum.to_a.join).must_equal '{}' 41 | end 42 | 43 | specify "shouldn't affect form-urlencoded requests" do 44 | env = mock_env(input: 'key=value', type: 'application/x-www-form-urlencoded') 45 | res = create_parser(app).call(env) 46 | _(res[2].to_enum.to_a.join).must_equal '{"key"=>"value"}' 47 | end 48 | 49 | specify "should not parse non-json media types" do 50 | env = mock_env(type: 'text/plain') 51 | res = create_parser(app).call(env) 52 | _(res[2].to_enum.to_a.join).must_equal '{}' 53 | end 54 | 55 | specify "shouldn't parse or error when CONTENT_TYPE is nil" do 56 | env = mock_env(type: nil) 57 | res = create_parser(app).call(env) 58 | _(res[2].to_enum.to_a.join).must_equal %Q({"{\\"key\\": \\"value\\"}"=>nil}) 59 | end 60 | 61 | specify "should not rescue JSON:ParserError raised by the app" do 62 | env = mock_env 63 | app = ->(env) { raise JSON::ParserError } 64 | _( -> { create_parser(app).call(env) }).must_raise JSON::ParserError 65 | end 66 | 67 | specify "should rescue StandardError subclasses raised by the parser" do 68 | class CustomParserError < StandardError; end 69 | 70 | parser = create_parser(app) do |_body| 71 | raise CustomParserError.new 72 | end 73 | 74 | res = parser.call(mock_env) 75 | _(res[0]).must_equal 400 76 | end 77 | 78 | describe "contradiction between body and type" do 79 | specify "should return bad request with a JSON-encoded error message" do 80 | env = mock_env(input: 'This is not JSON') 81 | status, headers, body = create_parser(app).call(env) 82 | _(status).must_equal 400 83 | _(headers['Content-Type']).must_equal 'application/json' 84 | body.each { |part| _(JSON.parse(part)['error']).wont_be_nil } 85 | end 86 | end 87 | 88 | describe "with configuration" do 89 | specify "should use a given block to parse the JSON body" do 90 | parser = create_parser(app) do |body| 91 | { 'payload' => JSON.parse(body) } 92 | end 93 | res = parser.call(mock_env) 94 | _(res[2].to_enum.to_a.join).must_equal '{"payload"=>{"key"=>"value"}}' 95 | end 96 | 97 | specify "should accept an array of HTTP verbs to parse" do 98 | env = mock_env.merge('REQUEST_METHOD' => 'GET') 99 | parser = create_parser(app, verbs: %w[GET]) 100 | 101 | res = parser.call(env) 102 | _(res[2].to_enum.to_a.join).must_equal '{"key"=>"value"}' 103 | 104 | res = parser.call(mock_env) 105 | _(res[2].to_enum.to_a.join).must_equal '{}' 106 | end 107 | 108 | specify "should accept an Array of media-types to parse" do 109 | parser = create_parser(app, media: ['application/json', 'text/plain']) 110 | env = mock_env(type: 'text/plain') 111 | res = parser.call(env) 112 | _(res[2].to_enum.to_a.join).must_equal '{"key"=>"value"}' 113 | 114 | html_env = mock_env(type: 'text/html') 115 | res = parser.call(html_env) 116 | _(res[2].to_enum.to_a.join).must_equal '{}' 117 | end 118 | 119 | specify "should accept a Regexp as a media-type matcher" do 120 | parser = create_parser(app, media: /json/) 121 | env = mock_env(type: 'weird/json.odd') 122 | res = parser.call(env) 123 | _(res[2].to_enum.to_a.join).must_equal '{"key"=>"value"}' 124 | end 125 | 126 | specify "should accept a String as a media-type matcher" do 127 | parser = create_parser(app, media: 'application/vnd.api+json') 128 | env = mock_env(type: 'application/vnd.api+json') 129 | res = parser.call(env) 130 | _(res[2].to_enum.to_a.join).must_equal '{"key"=>"value"}' 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Contributed Rack Middleware and Utilities 2 | 3 | This package includes a variety of add-on components for Rack, a Ruby web server 4 | interface: 5 | 6 | * `Rack::Access` - Limits access based on IP address 7 | * `Rack::Backstage` - Returns content of specified file if it exists, which makes it convenient for putting up maintenance pages. 8 | * `Rack::BounceFavicon` - Returns a 404 for requests to `/favicon.ico` 9 | * `Rack::CSSHTTPRequest` - Adds CSSHTTPRequest support by encoding responses as CSS for cross-site AJAX-style data loading 10 | * `Rack::Callbacks` - Implements DSL for pure before/after filter like Middlewares. 11 | * `Rack::Cookies` - Adds simple cookie jar hash to env 12 | * `Rack::Deflect` - Helps protect against DoS attacks. 13 | * `Rack::Evil` - Lets the rack application return a response to the client from any place. 14 | * `Rack::HostMeta` - Configures `/host-meta` using a block 15 | * `Rack::JSONBodyParser` - Adds JSON request bodies to the Rack parameters hash. 16 | * `Rack::JSONP` - Adds JSON-P support by stripping out the callback param and padding the response with the appropriate callback format. 17 | * `Rack::LazyConditionalGet` - Caches a global `Last-Modified` date and updates it each time there is a request that is not `GET` or `HEAD`. 18 | * `Rack::LighttpdScriptNameFix` - Fixes how lighttpd sets the `SCRIPT_NAME` and `PATH_INFO` variables in certain configurations. 19 | * `Rack::Locale` - Detects the client locale using the Accept-Language request header and sets a `rack.locale` variable in the environment. 20 | * `Rack::MailExceptions` - Rescues exceptions raised from the app and sends a useful email with the exception, stacktrace, and contents of the environment. 21 | * `Rack::NestedParams` - parses form params with subscripts (e.g., * "`post[title]=Hello`") into a nested/recursive Hash structure (based on Rails' implementation). 22 | * `Rack::NotFound` - A default 404 application. 23 | * `Rack::PostBodyContentTypeParser` - [Deprecated]: Adds support for JSON request bodies. The Rack parameter hash is populated by deserializing the JSON data provided in the request body when the Content-Type is application/json 24 | * `Rack::Printout` - Prints the environment and the response per request 25 | * `Rack::ProcTitle` - Displays request information in process title (`$0`) for monitoring/inspection with ps(1). 26 | * `Rack::Profiler` - Uses ruby-prof to measure request time. 27 | * `Rack::RelativeRedirect` - Transforms relative paths in redirects to absolute URLs. 28 | * `Rack::ResponseCache` - Caches responses to requests without query strings to Disk or a user provided Ruby object. Similar to Rails' page caching. 29 | * `Rack::ResponseHeaders` - Manipulates response headers object at runtime 30 | * `Rack::Signals` - Installs signal handlers that are safely processed after a request 31 | * `Rack::SimpleEndpoint` - Creates simple endpoints with routing rules, similar to Sinatra actions 32 | * `Rack::StaticCache` - Modifies the response headers to facilitiate client and proxy caching for static files that minimizes http requests and improves overall load times for second time visitors. 33 | * `Rack::TimeZone` - Detects the client's timezone using JavaScript and sets a variable in Rack's environment with the offset from UTC. 34 | * `Rack::TryStatic` - Tries to match request to a static file 35 | 36 | ### Use 37 | 38 | Git is the quickest way to the rack-contrib sources: 39 | 40 | git clone git://github.com/rack/rack-contrib.git 41 | 42 | Gems are available too: 43 | 44 | gem install rack-contrib 45 | 46 | Requiring `'rack/contrib'` will add autoloads to the Rack modules for all of the 47 | components included. The following example shows what a simple rackup 48 | (`config.ru`) file might look like: 49 | 50 | ```ruby 51 | require 'rack' 52 | require 'rack/contrib' 53 | 54 | use Rack::Profiler if ENV['RACK_ENV'] == 'development' 55 | 56 | use Rack::ETag 57 | use Rack::MailExceptions 58 | 59 | run theapp 60 | ``` 61 | 62 | #### Versioning 63 | 64 | This package is [semver compliant](https://semver.org); you should use a 65 | pessimistic version constraint (`~>`) against the relevant `2.x` version of 66 | this gem. 67 | 68 | This version of `rack-contrib` is compatible with `rack` 2.x and 3.x. If you 69 | are using `rack` 1.x, you will need to use `rack-contrib` 1.x. A suitable 70 | pessimistic version constraint (`~>`) is recommended. 71 | 72 | 73 | ### Testing 74 | 75 | To contribute to the project, begin by cloning the repo and installing the necessary gems: 76 | 77 | gem install bundler 78 | bundle install 79 | 80 | To run the entire test suite, run: 81 | 82 | bundle exec rake test 83 | 84 | To run a specific component's tests, use the `TEST` environment variable: 85 | 86 | TEST=test/spec_rack_thecomponent.rb bundle exec rake test 87 | 88 | ### Criteria for inclusion 89 | The criteria for middleware being included in this project are roughly as follows: 90 | * For patterns that are very common, provide a reference implementation so that other projects do not have to reinvent the wheel. 91 | * For patterns that are very useful or interesting, provide a well-done implementation. 92 | * The middleware fits in 1 code file and is relatively small. Currently all middleware in the project are < 150 LOC. 93 | * The middleware doesn't have any dependencies other than rack and the ruby standard library. 94 | 95 | These criteria were introduced several years after the start of the project, so some of the included middleware may not meet all of them. In particular, several middleware have external dependencies. It is possible that in some future release of rack-contrib, middleware with external depencies will be removed from the project. 96 | 97 | When submitting code keep the above criteria in mind and also see the code 98 | guidelines in CONTRIBUTING.md. 99 | 100 | ### Links 101 | 102 | * rack-contrib on GitHub:: 103 | * Rack:: 104 | * Rack On GitHub:: 105 | 106 | 107 | ### Security Reporting 108 | 109 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). 110 | Tidelift will coordinate the fix and disclosure. 111 | -------------------------------------------------------------------------------- /test/spec_rack_access.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/access' 6 | 7 | describe "Rack::Access" do 8 | 9 | before do 10 | @app = lambda { |env| [200, { 'content-type' => 'text/plain' }, ['hello']] } 11 | @mock_addr_1 = '111.111.111.111' 12 | @mock_addr_2 = '192.168.1.222' 13 | @mock_addr_localhost = '127.0.0.1' 14 | @mock_addr_range = '192.168.1.0/24' 15 | end 16 | 17 | def mock_env(remote_addr, path = '/') 18 | Rack::MockRequest.env_for(path, { 'REMOTE_ADDR' => remote_addr }) 19 | end 20 | 21 | def access(app, options = {}) 22 | Rack::Lint.new(Rack::Access.new(app, options)) 23 | end 24 | 25 | specify "default configuration should deny non-local requests" do 26 | req = Rack::MockRequest.new(access(@app)) 27 | res = req.get('/', 'REMOTE_ADDR' => @mock_addr_1) 28 | 29 | _(res.status).must_equal 403 30 | _(res.body).must_equal '' 31 | end 32 | 33 | specify "default configuration should allow requests from 127.0.0.1" do 34 | req = Rack::MockRequest.new(access(@app)) 35 | res = req.get('/', 'REMOTE_ADDR' => @mock_addr_localhost) 36 | 37 | _(res.status).must_equal 200 38 | _(res.body).must_equal 'hello' 39 | end 40 | 41 | specify "should allow remote addresses in allow_ipmasking" do 42 | req = Rack::MockRequest.new(access(@app, '/' => [@mock_addr_1])) 43 | res = req.get('/', 'REMOTE_ADDR' => @mock_addr_1) 44 | 45 | _(res.status).must_equal 200 46 | _(res.body).must_equal 'hello' 47 | end 48 | 49 | specify "should deny remote addresses not in allow_ipmasks" do 50 | req = Rack::MockRequest.new(access(@app, '/' => [@mock_addr_1])) 51 | res = req.get('/', 'REMOTE_ADDR' => @mock_addr_2) 52 | 53 | _(res.status).must_equal 403 54 | _(res.body).must_equal '' 55 | end 56 | 57 | specify "should allow remote addresses in allow_ipmasks range" do 58 | req = Rack::MockRequest.new(access(@app, '/' => [@mock_addr_range])) 59 | res = req.get('/', 'REMOTE_ADDR' => @mock_addr_2) 60 | 61 | _(res.status).must_equal 200 62 | _(res.body).must_equal 'hello' 63 | end 64 | 65 | specify "should deny remote addresses not in allow_ipmasks range" do 66 | req = Rack::MockRequest.new(access(@app, '/' => [@mock_addr_range])) 67 | res = req.get('/', 'REMOTE_ADDR' => @mock_addr_1) 68 | 69 | _(res.status).must_equal 403 70 | _(res.body).must_equal '' 71 | end 72 | 73 | specify "should allow remote addresses in one of allow_ipmasking" do 74 | req = Rack::MockRequest.new(access(@app, '/' => [@mock_addr_range, @mock_addr_localhost])) 75 | 76 | res = req.get('/', 'REMOTE_ADDR' => @mock_addr_2) 77 | _(res.status).must_equal 200 78 | _(res.body).must_equal 'hello' 79 | 80 | res = req.get('/', 'REMOTE_ADDR' => @mock_addr_localhost) 81 | _(res.status).must_equal 200 82 | _(res.body).must_equal 'hello' 83 | end 84 | 85 | specify "should deny remote addresses not in one of allow_ipmasks" do 86 | req = Rack::MockRequest.new(access(@app, '/' => [@mock_addr_range, @mock_addr_localhost])) 87 | res = req.get('/', 'REMOTE_ADDR' => @mock_addr_1) 88 | 89 | _(res.status).must_equal 403 90 | _(res.body).must_equal '' 91 | end 92 | 93 | specify "handles paths correctly" do 94 | req = Rack::MockRequest.new( 95 | access( 96 | @app, 97 | 'http://foo.org/bar' => [@mock_addr_localhost], 98 | '/foo' => [@mock_addr_localhost], 99 | '/foo/bar' => [@mock_addr_range, @mock_addr_localhost] 100 | ) 101 | ) 102 | 103 | res = req.get('/', 'REMOTE_ADDR' => @mock_addr_1) 104 | _(res.status).must_equal 200 105 | _(res.body).must_equal 'hello' 106 | 107 | res = req.get('/qux', 'REMOTE_ADDR' => @mock_addr_1) 108 | _(res.status).must_equal 200 109 | _(res.body).must_equal 'hello' 110 | 111 | res = req.get('/foo', 'REMOTE_ADDR' => @mock_addr_1) 112 | _(res.status).must_equal 403 113 | _(res.body).must_equal '' 114 | res = req.get('/foo', 'REMOTE_ADDR' => @mock_addr_localhost) 115 | _(res.status).must_equal 200 116 | _(res.body).must_equal 'hello' 117 | 118 | res = req.get('/foo/', 'REMOTE_ADDR' => @mock_addr_1) 119 | _(res.status).must_equal 403 120 | _(res.body).must_equal '' 121 | res = req.get('/foo/', 'REMOTE_ADDR' => @mock_addr_localhost) 122 | _(res.status).must_equal 200 123 | _(res.body).must_equal 'hello' 124 | 125 | res = req.get('/foo/bar', 'REMOTE_ADDR' => @mock_addr_1) 126 | _(res.status).must_equal 403 127 | _(res.body).must_equal '' 128 | res = req.get('/foo/bar', 'REMOTE_ADDR' => @mock_addr_localhost) 129 | _(res.status).must_equal 200 130 | _(res.body).must_equal 'hello' 131 | res = req.get('/foo/bar', 'REMOTE_ADDR' => @mock_addr_2) 132 | _(res.status).must_equal 200 133 | _(res.body).must_equal 'hello' 134 | 135 | res = req.get('/foo/bar/', 'REMOTE_ADDR' => @mock_addr_1) 136 | _(res.status).must_equal 403 137 | _(res.body).must_equal '' 138 | res = req.get('/foo/bar/', 'REMOTE_ADDR' => @mock_addr_localhost) 139 | _(res.status).must_equal 200 140 | _(res.body).must_equal 'hello' 141 | 142 | res = req.get('/foo///bar//quux', 'REMOTE_ADDR' => @mock_addr_1) 143 | _(res.status).must_equal 403 144 | _(res.body).must_equal '' 145 | res = req.get('/foo///bar//quux', 'REMOTE_ADDR' => @mock_addr_localhost) 146 | _(res.status).must_equal 200 147 | _(res.body).must_equal 'hello' 148 | 149 | res = req.get('/foo/quux', 'REMOTE_ADDR' => @mock_addr_1) 150 | _(res.status).must_equal 403 151 | _(res.body).must_equal '' 152 | res = req.get('/foo/quux', 'REMOTE_ADDR' => @mock_addr_localhost) 153 | _(res.status).must_equal 200 154 | _(res.body).must_equal 'hello' 155 | 156 | res = req.get('/bar', 'REMOTE_ADDR' => @mock_addr_1) 157 | _(res.status).must_equal 200 158 | _(res.body).must_equal 'hello' 159 | res = req.get('/bar', 'REMOTE_ADDR' => @mock_addr_1, 'HTTP_HOST' => 'foo.org') 160 | _(res.status).must_equal 403 161 | _(res.body).must_equal '' 162 | res = req.get('/bar', 'REMOTE_ADDR' => @mock_addr_localhost, 'HTTP_HOST' => 'foo.org') 163 | _(res.status).must_equal 200 164 | _(res.body).must_equal 'hello' 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /test/spec_rack_static_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | 5 | require 'rack' 6 | require 'rack/contrib/static_cache' 7 | require 'rack/mock' 8 | require 'timecop' 9 | 10 | class DummyApp 11 | def call(env) 12 | [200, {}, ["Hello World"]] 13 | end 14 | end 15 | 16 | describe "Rack::StaticCache" do 17 | def static_root 18 | ::File.expand_path(::File.dirname(__FILE__)) 19 | end 20 | 21 | def request(options) 22 | Rack::MockRequest.new(build_middleware(options)) 23 | end 24 | 25 | def build_middleware(options) 26 | options = { :root => static_root }.merge(options) 27 | Rack::Lint.new(Rack::StaticCache.new(DummyApp.new, options)) 28 | end 29 | 30 | describe "with a default app request" do 31 | def get_request(path) 32 | request(:urls => ["/statics"]).get(path) 33 | end 34 | 35 | it "should serve the request successfully" do 36 | _(get_request("/statics/test").ok?).must_equal(true) 37 | end 38 | 39 | it "should serve the correct file contents" do 40 | _(get_request("/statics/test").body).must_match(/rubyrack/) 41 | end 42 | 43 | it "should serve the correct file contents for a file with an extension" do 44 | _(get_request("/statics/test.html").body).must_match(/extensions rule!/) 45 | end 46 | 47 | it "should set a long Cache-Control max-age" do 48 | _(get_request("/statics/test").headers['Cache-Control']).must_equal 'max-age=31536000, public' 49 | end 50 | 51 | it "should set a long-distant Expires header" do 52 | next_year = Time.now().year + 1 53 | _(get_request("/statics/test").headers['Expires']).must_match( 54 | Regexp.new( 55 | "[A-Z][a-z]{2}[,][\s][0-9]{2}[\s][A-Z][a-z]{2}[\s]" + 56 | "#{next_year}" + 57 | "[\s][0-9]{2}[:][0-9]{2}[:][0-9]{2} GMT$" 58 | ) 59 | ) 60 | end 61 | 62 | it "should set Expires header based on current UTC time" do 63 | Timecop.freeze(DateTime.parse("2020-03-28 23:51 UTC")) do 64 | _(get_request("/statics/test").headers['Expires']).must_match("Sun, 28 Mar 2021 23:51:00 GMT") # now + 1 year 65 | end 66 | end 67 | 68 | it "should not cache expiration date between requests" do 69 | middleware = build_middleware(:urls => ["/statics"]) 70 | 71 | Timecop.freeze(DateTime.parse("2020-03-28 23:41 UTC")) do 72 | r = Rack::MockRequest.new(middleware) 73 | _(r.get("/statics/test").headers["Expires"]).must_equal "Sun, 28 Mar 2021 23:41:00 GMT" # time now + 1 year 74 | end 75 | 76 | Timecop.freeze(DateTime.parse("2020-03-28 23:51 UTC")) do 77 | r = Rack::MockRequest.new(middleware) 78 | _(r.get("/statics/test").headers["Expires"]).must_equal "Sun, 28 Mar 2021 23:51:00 GMT" # time now + 1 year 79 | end 80 | end 81 | 82 | it "should set Date header with current GMT time" do 83 | Timecop.freeze(DateTime.parse('2020-03-28 22:51 UTC')) do 84 | _(get_request("/statics/test").headers['Date']).must_equal 'Sat, 28 Mar 2020 22:51:00 GMT' 85 | end 86 | end 87 | 88 | it "should return 404s if url root is known but it can't find the file" do 89 | _(get_request("/statics/non-existent").not_found?).must_equal(true) 90 | end 91 | 92 | it "should call down the chain if url root is not known" do 93 | res = get_request("/something/else") 94 | _(res.ok?).must_equal(true) 95 | _(res.body).must_equal "Hello World" 96 | end 97 | 98 | it "should serve files if requested with version number" do 99 | res = get_request("/statics/test-0.0.1") 100 | _(res.ok?).must_equal(true) 101 | end 102 | 103 | it "should serve the correct file contents for a file with an extension requested with a version" do 104 | _(get_request("/statics/test-0.0.1.html").body).must_match(/extensions rule!/) 105 | end 106 | end 107 | 108 | describe "with a custom version number regex" do 109 | def get_request(path) 110 | request(:urls => ["/statics"], :version_regex => /-[0-9a-f]{8}/).get(path) 111 | end 112 | 113 | it "should handle requests with the custom regex" do 114 | _(get_request("/statics/test-deadbeef").ok?).must_equal(true) 115 | end 116 | 117 | it "should handle extensioned requests for the custom regex" do 118 | _(get_request("/statics/test-deadbeef.html").body).must_match(/extensions rule!/) 119 | end 120 | 121 | it "should not handle requests for the default version regex" do 122 | _(get_request("/statics/test-0.0.1").ok?).must_equal(false) 123 | end 124 | end 125 | 126 | describe "with custom cache duration" do 127 | def get_request(path) 128 | request(:urls => ["/statics"], :duration => 2).get(path) 129 | end 130 | 131 | it "should change cache duration" do 132 | next_next_year = Time.now().year + 2 133 | _(get_request("/statics/test").headers['Expires']).must_match(Regexp.new("#{next_next_year}")) 134 | end 135 | end 136 | 137 | describe "with partial-year cache duration" do 138 | def get_request(path) 139 | request(:urls => ["/statics"], :duration => 1.0 / 52).get(path) 140 | end 141 | 142 | it "should round max-age if duration is part of a year" do 143 | _(get_request("/statics/test").headers['Cache-Control']).must_equal "max-age=606461, public" 144 | end 145 | end 146 | 147 | describe "with versioning disabled" do 148 | def get_request(path) 149 | request(:urls => ["/statics"], :versioning => false).get(path) 150 | end 151 | 152 | it "should return 404s if requested with version number" do 153 | _(get_request("/statics/test-0.0.1").not_found?).must_equal(true) 154 | end 155 | end 156 | 157 | describe "with * suffix on directory name" do 158 | def get_request(path) 159 | request(:urls => ["/statics*"]).get(path) 160 | end 161 | 162 | it "should serve files OK" do 163 | _(get_request("/statics/test").ok?).must_equal(true) 164 | end 165 | 166 | it "should serve the content" do 167 | _(get_request("/statics/test").body).must_match(/rubyrack/) 168 | end 169 | 170 | it "should not set a max-age" do 171 | _(get_request("/statics/test").headers['Cache-Control']).must_be_nil 172 | end 173 | 174 | it "should not set an Expires header" do 175 | _(get_request("/statics/test").headers['Expires']).must_be_nil 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /test/spec_rack_response_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/response_cache' 6 | require 'fileutils' 7 | 8 | describe Rack::ResponseCache do 9 | def request(opts={}, &block) 10 | Rack::MockRequest.new(Rack::Lint.new(Rack::ResponseCache.new(block||@def_app, opts[:cache]||@cache, &opts[:rc_block]))).send(opts[:meth]||:get, opts[:path]||@def_path, opts[:headers]||{}) 11 | end 12 | 13 | before do 14 | @cache = {} 15 | @def_disk_cache = ::File.join(::File.dirname(__FILE__), 'response_cache_test_disk_cache') 16 | @def_value = ["rack-response-cache"] 17 | @def_path = '/path/to/blah' 18 | @def_app = lambda { |env| [200, {'Content-Type' => env['CT'] || 'text/html'}, @def_value]} 19 | end 20 | after do 21 | FileUtils.rm_rf(@def_disk_cache) 22 | end 23 | 24 | specify "should cache results to disk if cache is a string" do 25 | request(:cache=>@def_disk_cache) 26 | _(::File.read(::File.join(@def_disk_cache, 'path', 'to', 'blah.html'))).must_equal @def_value.first 27 | request(:path=>'/path/3', :cache=>@def_disk_cache) 28 | _(::File.read(::File.join(@def_disk_cache, 'path', '3.html'))).must_equal @def_value.first 29 | end 30 | 31 | specify "should cache results to given cache if cache is not a string" do 32 | request 33 | _(@cache).must_equal('/path/to/blah.html'=>@def_value) 34 | request(:path=>'/path/3') 35 | _(@cache).must_equal('/path/to/blah.html'=>@def_value, '/path/3.html'=>@def_value) 36 | end 37 | 38 | specify "should run a case-insenstive lookup of the Content-Type header" do 39 | body = ['ok'] 40 | request { |env| [200, {'content-type' => 'text/html'}, body]} 41 | _(@cache).must_equal('/path/to/blah.html'=> body) 42 | request { |env| [200, {'ConTENT-TyPe' => 'text/html'}, body]} 43 | _(@cache).must_equal('/path/to/blah.html'=> body) 44 | end 45 | 46 | specify "should not CACHE RESults if request method is not GET" do 47 | request(:meth=>:post) 48 | _(@cache).must_equal({}) 49 | request(:meth=>:put) 50 | _(@cache).must_equal({}) 51 | request(:meth=>:delete) 52 | _(@cache).must_equal({}) 53 | end 54 | 55 | specify "should not cache results if there is a query string" do 56 | request(:path=>'/path/to/blah?id=1') 57 | _(@cache).must_equal({}) 58 | request(:path=>'/path/to/?id=1') 59 | _(@cache).must_equal({}) 60 | request(:path=>'/?id=1') 61 | _(@cache).must_equal({}) 62 | end 63 | 64 | specify "should cache results if there is an empty query string" do 65 | request(:path=>'/?') 66 | _(@cache).must_equal('/index.html'=>@def_value) 67 | end 68 | 69 | specify "should not cache results if the request is not successful (status 200)" do 70 | request{|env| [404, {'Content-Type' => 'text/html'}, ['']]} 71 | _(@cache).must_equal({}) 72 | request{|env| [500, {'Content-Type' => 'text/html'}, ['']]} 73 | _(@cache).must_equal({}) 74 | request{|env| [302, {'Content-Type' => 'text/html'}, ['']]} 75 | _(@cache).must_equal({}) 76 | end 77 | 78 | specify "should not cache results if the block returns nil or false" do 79 | request(:rc_block=>proc{false}) 80 | _(@cache).must_equal({}) 81 | request(:rc_block=>proc{nil}) 82 | _(@cache).must_equal({}) 83 | end 84 | 85 | specify "should cache results to path returned by block" do 86 | request(:rc_block=>proc{"1"}) 87 | _(@cache).must_equal("1"=>@def_value) 88 | request(:rc_block=>proc{"2"}) 89 | _(@cache).must_equal("1"=>@def_value, "2"=>@def_value) 90 | end 91 | 92 | specify "should pass the environment and response to the block" do 93 | e, r = nil, nil 94 | request(:rc_block=>proc{|env,res| e, r = env, [res[0], res[1].dup, res[2].dup]; nil}) 95 | _(e['PATH_INFO']).must_equal @def_path 96 | _(e['REQUEST_METHOD']).must_equal 'GET' 97 | _(e['QUERY_STRING']).must_equal '' 98 | if Rack.release < "3" 99 | _(r).must_equal([200, {"Content-Type"=>"text/html"}, ["rack-response-cache"]]) 100 | else 101 | _(r).must_equal([200, {"content-type"=>"text/html"}, ["rack-response-cache"]]) 102 | end 103 | end 104 | 105 | specify "should unescape the path by default" do 106 | request(:path=>'/path%20with%20spaces') 107 | _(@cache).must_equal('/path with spaces.html'=>@def_value) 108 | request(:path=>'/path%3chref%3e') 109 | _(@cache).must_equal('/path with spaces.html'=>@def_value, '/path.html'=>@def_value) 110 | end 111 | 112 | specify "should cache html, css, and xml responses by default" do 113 | request(:path=>'/a') 114 | _(@cache).must_equal('/a.html'=>@def_value) 115 | request(:path=>'/b', :headers=>{'CT'=>'text/xml'}) 116 | _(@cache).must_equal('/a.html'=>@def_value, '/b.xml'=>@def_value) 117 | request(:path=>'/c', :headers=>{'CT'=>'text/css'}) 118 | _(@cache).must_equal('/a.html'=>@def_value, '/b.xml'=>@def_value, '/c.css'=>@def_value) 119 | end 120 | 121 | specify "should cache responses by default with the extension added if not already present" do 122 | request(:path=>'/a.html') 123 | _(@cache).must_equal('/a.html'=>@def_value) 124 | request(:path=>'/b.xml', :headers=>{'CT'=>'text/xml'}) 125 | _(@cache).must_equal('/a.html'=>@def_value, '/b.xml'=>@def_value) 126 | request(:path=>'/c.css', :headers=>{'CT'=>'text/css'}) 127 | _(@cache).must_equal('/a.html'=>@def_value, '/b.xml'=>@def_value, '/c.css'=>@def_value) 128 | end 129 | 130 | specify "should not delete existing extensions" do 131 | request(:path=>'/d.css', :headers=>{'CT'=>'text/html'}) 132 | _(@cache).must_equal('/d.css.html'=>@def_value) 133 | end 134 | 135 | specify "should cache html responses with empty basename to index.html by default" do 136 | request(:path=>'/') 137 | _(@cache).must_equal('/index.html'=>@def_value) 138 | request(:path=>'/blah/') 139 | _(@cache).must_equal('/index.html'=>@def_value, '/blah/index.html'=>@def_value) 140 | request(:path=>'/blah/2/') 141 | _(@cache).must_equal('/index.html'=>@def_value, '/blah/index.html'=>@def_value, '/blah/2/index.html'=>@def_value) 142 | end 143 | 144 | specify "should raise an error if a cache argument is not provided" do 145 | app = Rack::Lint.new(Rack::Builder.new{use Rack::ResponseCache; run lambda { |env| [200, {'Content-Type' => 'text/plain'}, []]}}) 146 | _(proc{Rack::MockRequest.new(app).get('/')}).must_raise(ArgumentError) 147 | end 148 | 149 | end 150 | -------------------------------------------------------------------------------- /test/spec_rack_jsonp.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest/autorun' 4 | require 'rack/mock' 5 | require 'rack/contrib/jsonp' 6 | 7 | describe "Rack::JSONP" do 8 | def jsonp(app) 9 | Rack::Lint.new(Rack::JSONP.new(app)) 10 | end 11 | 12 | def normalize_response(response) 13 | response.tap do |ary| 14 | ary[2] = ary[2].to_enum.to_a 15 | end 16 | end 17 | 18 | describe "when a callback parameter is provided" do 19 | specify "should wrap the response body in the Javascript callback if JSON" do 20 | test_body = '{"bar":"foo"}' 21 | callback = 'foo' 22 | app = lambda { |env| [200, {'content-type' => 'application/json'}, [test_body]] } 23 | request = Rack::MockRequest.env_for("/", :params => "foo=bar&callback=#{callback}") 24 | body = jsonp(app).call(request).last 25 | _(body.to_enum.to_a).must_equal ["/**/#{callback}(#{test_body})"] 26 | end 27 | 28 | specify "should not wrap the response body in a callback if body is not JSON" do 29 | test_body = '{"bar":"foo"}' 30 | callback = 'foo' 31 | app = lambda { |env| [200, {'content-type' => 'text/plain'}, [test_body]] } 32 | request = Rack::MockRequest.env_for("/", :params => "foo=bar&callback=#{callback}") 33 | body = jsonp(app).call(request).last 34 | _(body.to_enum.to_a).must_equal ['{"bar":"foo"}'] 35 | end 36 | 37 | specify "should update content length if it was set" do 38 | test_body = '{"bar":"foo"}' 39 | callback = 'foo' 40 | app = lambda { |env| [200, {'content-type' => 'application/json', 'Content-Length' => test_body.length}, [test_body]] } 41 | request = Rack::MockRequest.env_for("/", :params => "foo=bar&callback=#{callback}") 42 | 43 | headers = jsonp(app).call(request)[1] 44 | expected_length = "/**/".length + test_body.length + callback.length + "()".length 45 | _(headers['Content-Length']).must_equal(expected_length.to_s) 46 | end 47 | 48 | specify "should not touch content length if not set" do 49 | test_body = '{"bar":"foo"}' 50 | callback = 'foo' 51 | app = lambda { |env| [200, {'content-type' => 'application/json'}, [test_body]] } 52 | request = Rack::MockRequest.env_for("/", :params => "foo=bar&callback=#{callback}") 53 | headers = jsonp(app).call(request)[1] 54 | _(headers['Content-Length']).must_be_nil 55 | end 56 | 57 | specify "should modify the content type to application/javascript" do 58 | test_body = '{"bar":"foo"}' 59 | callback = 'foo' 60 | app = lambda { |env| [200, {'content-type' => 'application/json'}, [test_body]] } 61 | request = Rack::MockRequest.env_for("/", :params => "foo=bar&callback=#{callback}") 62 | headers = jsonp(app).call(request)[1] 63 | _(headers['content-type']).must_equal('application/javascript') 64 | end 65 | 66 | specify "should not allow literal U+2028 or U+2029" do 67 | test_body = unless "\u2028" == 'u2028' 68 | "{\"bar\":\"\u2028 and \u2029\"}" 69 | else 70 | "{\"bar\":\"\342\200\250 and \342\200\251\"}" 71 | end 72 | callback = 'foo' 73 | app = lambda { |env| [200, {'content-type' => 'application/json'}, [test_body]] } 74 | request = Rack::MockRequest.env_for("/", :params => "foo=bar&callback=#{callback}") 75 | body = jsonp(app).call(request).last 76 | unless "\u2028" == 'u2028' 77 | _(body.to_enum.to_a.join).wont_match(/\u2028|\u2029/) 78 | else 79 | _(body.to_enum.to_a.join).wont_match(/\342\200\250|\342\200\251/) 80 | end 81 | end 82 | 83 | describe "but is empty" do 84 | specify "with assignment" do 85 | test_body = '{"bar":"foo"}' 86 | callback = '' 87 | app = lambda { |env| [200, {'content-type' => 'application/json'}, [test_body]] } 88 | request = Rack::MockRequest.env_for("/", :params => "foo=bar&callback=#{callback}") 89 | body = jsonp(app).call(request).last 90 | _(body.to_enum.to_a).must_equal ['{"bar":"foo"}'] 91 | end 92 | 93 | specify "without assignment" do 94 | test_body = '{"bar":"foo"}' 95 | app = lambda { |env| [200, {'content-type' => 'application/json'}, [test_body]] } 96 | request = Rack::MockRequest.env_for("/", :params => "foo=bar&callback") 97 | body = jsonp(app).call(request).last 98 | _(body.to_enum.to_a).must_equal ['{"bar":"foo"}'] 99 | end 100 | end 101 | 102 | describe 'but is invalid' do 103 | describe 'with content-type application/json' do 104 | specify 'should return "Bad Request"' do 105 | test_body = '{"bar":"foo"}' 106 | callback = '*' 107 | content_type = 'application/json' 108 | app = lambda { |env| [200, {'content-type' => content_type}, [test_body]] } 109 | request = Rack::MockRequest.env_for("/", :params => "foo=bar&callback=#{callback}") 110 | body = jsonp(app).call(request).last 111 | _(body.to_enum.to_a).must_equal ['Bad Request'] 112 | end 113 | 114 | specify 'should return set the response code to 400' do 115 | test_body = '{"bar":"foo"}' 116 | callback = '*' 117 | content_type = 'application/json' 118 | app = lambda { |env| [200, {'content-type' => content_type}, [test_body]] } 119 | request = Rack::MockRequest.env_for("/", :params => "foo=bar&callback=#{callback}") 120 | response_code = jsonp(app).call(request).first 121 | _(response_code).must_equal 400 122 | end 123 | end 124 | 125 | describe 'with content-type text/plain' do 126 | specify 'should return "Good Request"' do 127 | test_body = 'Good Request' 128 | callback = '*' 129 | content_type = 'text/plain' 130 | app = lambda { |env| [200, {'content-type' => content_type}, [test_body]] } 131 | request = Rack::MockRequest.env_for("/", :params => "foo=bar&callback=#{callback}") 132 | body = jsonp(app).call(request).last 133 | _(body.to_enum.to_a).must_equal ['Good Request'] 134 | end 135 | 136 | specify 'should not change the response code from 200' do 137 | test_body = '{"bar":"foo"}' 138 | callback = '*' 139 | content_type = 'text/plain' 140 | app = lambda { |env| [200, {'content-type' => content_type}, [test_body]] } 141 | request = Rack::MockRequest.env_for("/", :params => "foo=bar&callback=#{callback}") 142 | response_code = jsonp(app).call(request).first 143 | _(response_code).must_equal 200 144 | end 145 | end 146 | end 147 | 148 | describe "with XSS vulnerability attempts" do 149 | def request(callback, body = '{"bar":"foo"}') 150 | app = lambda { |env| [200, {'content-type' => 'application/json'}, [body]] } 151 | request = Rack::MockRequest.env_for("/", :params => "foo=bar&callback=#{callback}") 152 | jsonp(app).call(request) 153 | end 154 | 155 | def assert_bad_request(response) 156 | _(response).wont_be_nil 157 | status, headers, body = response 158 | _(status).must_equal 400 159 | _(body.to_enum.to_a).must_equal ["Bad Request"] 160 | end 161 | 162 | specify "should return bad request for callback with invalid characters" do 163 | assert_bad_request(request("foobaz()$")) 164 | end 165 | 166 | specify "should return bad request for callbacks with ")) 168 | end 169 | 170 | specify "should return bad requests for callbacks with multiple statements" do 171 | assert_bad_request(request("foo%3balert(1)//")) # would render: "foo;alert(1)//" 172 | end 173 | 174 | specify "should not return a bad request for callbacks with dots in the callback" do 175 | status, headers, body = request(callback = "foo.bar.baz", test_body = '{"foo":"bar"}') 176 | _(status).must_equal 200 177 | _(body.to_enum.to_a).must_equal ["/**/#{callback}(#{test_body})"] 178 | end 179 | end 180 | 181 | end 182 | 183 | specify "should not change anything if no callback param is provided" do 184 | test_body = ['{"bar":"foo"}'] 185 | app = lambda { |env| [200, {'content-type' => 'application/json'}, test_body] } 186 | request = Rack::MockRequest.env_for("/", :params => "foo=bar") 187 | body = jsonp(app).call(request).last 188 | _(body.to_enum.to_a).must_equal test_body 189 | end 190 | 191 | specify "should not change anything if it's not a json response" do 192 | test_body = '404 Not Found' 193 | app = lambda { |env| [404, {'content-type' => 'text/html'}, [test_body]] } 194 | request = Rack::MockRequest.env_for("/", :params => "callback=foo", 'HTTP_ACCEPT' => 'application/json') 195 | body = jsonp(app).call(request).last 196 | _(body.to_enum.to_a).must_equal [test_body] 197 | end 198 | 199 | specify "should not change anything if there is no content-type header" do 200 | test_body = '404 Not Found' 201 | app = lambda { |env| [404, {}, [test_body]] } 202 | request = Rack::MockRequest.env_for("/", :params => "callback=foo", 'HTTP_ACCEPT' => 'application/json') 203 | body = jsonp(app).call(request).last 204 | _(body.to_enum.to_a).must_equal [test_body] 205 | end 206 | 207 | specify "should not change anything if the request doesn't have a body" do 208 | app1 = lambda { |env| [100, {}, []] } 209 | app2 = lambda { |env| [204, {}, []] } 210 | app3 = lambda { |env| [304, {}, []] } 211 | request = Rack::MockRequest.env_for("/", :params => "callback=foo", 'HTTP_ACCEPT' => 'application/json') 212 | _(normalize_response(jsonp(app1).call(request))).must_equal app1.call(Rack::MockRequest.env_for('/')) 213 | _(normalize_response(jsonp(app2).call(request))).must_equal app2.call(Rack::MockRequest.env_for('/')) 214 | _(normalize_response(jsonp(app3).call(request))).must_equal app3.call(Rack::MockRequest.env_for('/')) 215 | end 216 | end 217 | --------------------------------------------------------------------------------