butler_static 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. data/.gitignore +17 -0
  2. data/Gemfile +4 -0
  3. data/Guardfile +14 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +29 -0
  6. data/Rakefile +12 -0
  7. data/butler.gemspec +31 -0
  8. data/lib/butler/asset.rb +147 -0
  9. data/lib/butler/handler.rb +92 -0
  10. data/lib/butler/railtie.rb +6 -0
  11. data/lib/butler/static.rb +97 -0
  12. data/lib/butler/version.rb +3 -0
  13. data/lib/butler.rb +9 -0
  14. data/spec/butler/asset_spec.rb +226 -0
  15. data/spec/butler/files/index.html +1 -0
  16. data/spec/minitest_helper.rb +17 -0
  17. data/test/butler/fixtures/public/.gitignore +1 -0
  18. data/test/butler/fixtures/public/404.html +1 -0
  19. data/test/butler/fixtures/public/500.da.html +1 -0
  20. data/test/butler/fixtures/public/500.html +1 -0
  21. data/test/butler/fixtures/public/elsewhere/cools.js +1 -0
  22. data/test/butler/fixtures/public/elsewhere/file.css +1 -0
  23. data/test/butler/fixtures/public/foo/bar.html +1 -0
  24. data/test/butler/fixtures/public/foo/baz.css +3 -0
  25. data/test/butler/fixtures/public/foo/index.html +1 -0
  26. data/test/butler/fixtures/public/foo//343/201/223/343/202/223/343/201/253/343/201/241/343/201/257.html +1 -0
  27. data/test/butler/fixtures/public/images/rails.png +0 -0
  28. data/test/butler/fixtures/public/index.html +1 -0
  29. data/test/butler/fixtures/public/javascripts/application.js +1 -0
  30. data/test/butler/fixtures/public/javascripts/bank.js +1 -0
  31. data/test/butler/fixtures/public/javascripts/common.javascript +1 -0
  32. data/test/butler/fixtures/public/javascripts/controls.js +1 -0
  33. data/test/butler/fixtures/public/javascripts/dragdrop.js +1 -0
  34. data/test/butler/fixtures/public/javascripts/effects.js +1 -0
  35. data/test/butler/fixtures/public/javascripts/prototype.js +1 -0
  36. data/test/butler/fixtures/public/javascripts/robber.js +1 -0
  37. data/test/butler/fixtures/public/javascripts/subdir/subdir.js +1 -0
  38. data/test/butler/fixtures/public/javascripts/version.1.0.js +1 -0
  39. data/test/butler/fixtures/public/stylesheets/bank.css +1 -0
  40. data/test/butler/fixtures/public/stylesheets/random.styles +1 -0
  41. data/test/butler/fixtures/public/stylesheets/robber.css +1 -0
  42. data/test/butler/fixtures/public/stylesheets/subdir/subdir.css +1 -0
  43. data/test/butler/fixtures/public/stylesheets/version.1.0.css +1 -0
  44. data/test/butler/fixtures/public/stylesheets/webfont.eot +1 -0
  45. data/test/butler/static_test.rb +195 -0
  46. metadata +221 -0
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in butler.gemspec
4
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,14 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ guard 'minitest' do
5
+ # with Minitest::Unit
6
+ watch(%r|^test/(.*)test\.rb|)
7
+ watch(%r|^lib/(.*)([^/]+)\.rb|) { |m| "test/#{m[1]}#{m[2]}_test.rb" }
8
+ watch(%r|^test/test_helper\.rb|) { "test" }
9
+
10
+ # with Minitest::Spec
11
+ watch(%r|^spec/(.*)_spec\.rb|)
12
+ watch(%r|^lib/(.*)([^/]+)\.rb|) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" }
13
+ watch(%r|^spec/spec_helper\.rb|) { "spec" }
14
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2012 Thomas Klemm
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # Butler
2
+
3
+ TODO: Write a gem description
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'butler_static'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install butler_static
18
+
19
+ ## Usage
20
+
21
+ TODO: Write usage instructions here
22
+
23
+ ## Contributing
24
+
25
+ 1. Fork it
26
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
27
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
28
+ 4. Push to the branch (`git push origin my-new-feature`)
29
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # Gem
2
+ require 'bundler/gem_tasks'
3
+
4
+ # Tests
5
+ require 'guard'
6
+ task :test do
7
+ Guard.setup
8
+ Guard.guards('minitest').run_all
9
+ end
10
+
11
+ # Alias default task to test
12
+ task default: :test
data/butler.gemspec ADDED
@@ -0,0 +1,31 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'butler/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "butler_static"
8
+ gem.version = Butler::VERSION
9
+ gem.authors = ["Thomas Klemm"]
10
+ gem.email = ["github@tklemm.eu"]
11
+ gem.description = "Butler is a Rack Middleware that serves static assets for your Rails app."
12
+ gem.summary = "Butler is a Rack Middleware that serves static assets for your Rails app. It allows you to set HTTP headers for individual files or folders based on rules."
13
+ gem.homepage = "https://github.com/thomasklemm/butler"
14
+
15
+ gem.files = `git ls-files`.split($/)
16
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
+ gem.require_paths = ["lib"]
19
+
20
+ # Dependencies
21
+ gem.add_dependency 'rack'
22
+
23
+ # Tests
24
+ gem.add_development_dependency 'minitest'
25
+ gem.add_development_dependency 'guard-minitest'
26
+ gem.add_development_dependency 'turn'
27
+ # Only ActiveSupport and ActionController nescessary
28
+ # gem.add_development_dependency 'rails', '~> 3.2.8'
29
+ gem.add_development_dependency 'activesupport', '~> 3.2.8'
30
+ gem.add_development_dependency 'actionpack', '~> 3.2.8'
31
+ end
@@ -0,0 +1,147 @@
1
+ require 'time'
2
+ require 'rack/utils'
3
+ require 'rack/mime'
4
+
5
+ module Butler
6
+ #
7
+ # Butler::Asset
8
+ #
9
+ # Sends a single requested file out to the user
10
+ # The code has been largely taken from Rack::File
11
+ # but adjusted to support sending custuom HTTP headers
12
+ #
13
+ # Usage:
14
+ # Butler::Asset.new(root_directory, headers: {
15
+ # 'Cache-Control' => 'public, max-age=31536000',
16
+ # 'Some custom header' => 'Content for some custom header'
17
+ # })
18
+ #
19
+ class Asset
20
+ SEPS = Regexp.union(*[File::SEPARATOR, File::ALT_SEPARATOR].compact)
21
+ ALLOWED_VERBS = %w[GET HEAD]
22
+
23
+ attr_accessor :root, :path
24
+ alias :to_path :path
25
+
26
+ def initialize(root, options={})
27
+ @root = root
28
+ @headers = options[:headers] || {}
29
+ end
30
+
31
+ def call(env)
32
+ dup._call(env)
33
+ end
34
+
35
+ F = File
36
+
37
+ def _call(env)
38
+ unless ALLOWED_VERBS.include? env["REQUEST_METHOD"]
39
+ return fail(405, "Method Not Allowed")
40
+ end
41
+
42
+ path_info = Rack::Utils.unescape(env["PATH_INFO"])
43
+ parts = path_info.split SEPS
44
+
45
+ parts.inject(0) do |depth, part|
46
+ case part
47
+ when '', '.'
48
+ depth
49
+ when '..'
50
+ return fail(404, "Not Found") if depth - 1 < 0
51
+ depth - 1
52
+ else
53
+ depth + 1
54
+ end
55
+ end
56
+
57
+ @path = F.join(@root, *parts)
58
+
59
+ available = begin
60
+ F.file?(@path) && F.readable?(@path)
61
+ rescue SystemCallError
62
+ false
63
+ end
64
+
65
+ if available
66
+ serving(env)
67
+ else
68
+ fail(404, "File not found: #{path_info}")
69
+ end
70
+ end
71
+
72
+ def serving(env)
73
+ last_modified = F.mtime(@path).httpdate
74
+ return [304, {}, []] if env['HTTP_IF_MODIFIED_SINCE'] == last_modified
75
+ response = [
76
+ 200,
77
+ {
78
+ "Last-Modified" => last_modified,
79
+ "Content-Type" => Rack::Mime.mime_type(F.extname(@path), 'text/plain')
80
+ },
81
+ env["REQUEST_METHOD"] == "HEAD" ? [] : self
82
+ ]
83
+
84
+ # Set headers
85
+ @headers.each { |field, content| response[1][field] = content } if @headers
86
+
87
+ # NOTE:
88
+ # We check via File::size? whether this file provides size info
89
+ # via stat (e.g. /proc files often don't), otherwise we have to
90
+ # figure it out by reading the whole file into memory.
91
+ size = F.size?(@path) || Utils.bytesize(F.read(@path))
92
+
93
+ ranges = Rack::Utils.byte_ranges(env, size)
94
+ if ranges.nil? || ranges.length > 1
95
+ # No ranges, or multiple ranges (which we don't support):
96
+ # TODO: Support multiple byte-ranges
97
+ response[0] = 200
98
+ @range = 0..size-1
99
+ elsif ranges.empty?
100
+ # Unsatisfiable. Return error, and file size:
101
+ response = fail(416, "Byte range unsatisfiable")
102
+ response[1]["Content-Range"] = "bytes */#{size}"
103
+ return response
104
+ else
105
+ # Partial content:
106
+ @range = ranges[0]
107
+ response[0] = 206
108
+ response[1]["Content-Range"] =
109
+ "bytes #{@range.begin}-#{@range.end}/#{size}"
110
+ size = @range.end - @range.begin + 1
111
+ end
112
+
113
+ response[1]["Content-Length"] = size.to_s
114
+ response
115
+ end
116
+
117
+ def each
118
+ F.open(@path, "rb") do |file|
119
+ file.seek(@range.begin)
120
+ remaining_len = @range.end-@range.begin+1
121
+ while remaining_len > 0
122
+ part = file.read([8192, remaining_len].min)
123
+ break unless part
124
+ remaining_len -= part.length
125
+
126
+ yield part
127
+ end
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def fail(status, body)
134
+ body += "\n"
135
+ [
136
+ status,
137
+ {
138
+ "Content-Type" => "text/plain",
139
+ "Content-Length" => body.size.to_s,
140
+ "X-Cascade" => "pass"
141
+ },
142
+ [body]
143
+ ]
144
+ end
145
+
146
+ end
147
+ end
@@ -0,0 +1,92 @@
1
+ require 'rack/utils'
2
+ require 'active_support/core_ext/uri'
3
+ require 'butler/asset'
4
+
5
+ module Butler
6
+ #
7
+ # Butler::Handler
8
+ #
9
+ # The link between Butler::Static and Butler::Asset
10
+ # Transforms HTTP header rules into actual HTTP headers
11
+ # for a single file to be set by Butler::Asset
12
+ #
13
+ # Code adapted from Rails' ActionDispatch::FileHandler
14
+ #
15
+ class Handler
16
+ def initialize(root, options={})
17
+ @root = root.chomp('/')
18
+ @compiled_root = /^#{Regexp.escape(root)}/
19
+ @headers = {}
20
+ @header_rules = options[:header_rules] || {}
21
+ @file_server = Butler::Asset.new(@root, headers: @headers)
22
+ end
23
+
24
+ def match?(path)
25
+ path = path.dup
26
+
27
+ full_path = path.empty? ? @root : File.join(@root,
28
+ escape_glob_chars(unescape_path(path)))
29
+ paths = "#{full_path}#{ext}"
30
+
31
+ matches = Dir[paths]
32
+ match = matches.detect { |m| File.file?(m) }
33
+ if match
34
+ match.sub!(@compiled_root, '')
35
+ ::Rack::Utils.escape(match)
36
+ end
37
+ end
38
+
39
+ def call(env)
40
+ @path = env['PATH_INFO']
41
+ set_headers
42
+ @file_server.call(env)
43
+ end
44
+
45
+ def ext
46
+ @ext ||= begin
47
+ ext = ::ActionController::Base.page_cache_extension
48
+ "{,#{ext},/index#{ext}}"
49
+ end
50
+ end
51
+
52
+ def unescape_path(path)
53
+ URI.parser.unescape(path)
54
+ end
55
+
56
+ def escape_glob_chars(path)
57
+ path.force_encoding('binary') if path.respond_to? :force_encoding
58
+ path.gsub(/[*?{}\[\]]/, "\\\\\\&")
59
+ end
60
+
61
+ # Convert header rules to headers
62
+ def set_headers
63
+ @header_rules.each do |rule, result|
64
+ case rule
65
+ when :global
66
+ set_header(result)
67
+ when :fonts
68
+ set_header(result) if @path.match(%r{\.(?:ttf|otf|eot|woff|svg)\z})
69
+ when Regexp
70
+ set_header(result) if @path.match(rule)
71
+ when Array
72
+ # Extensions
73
+ extensions = rule.join('|')
74
+ set_header(result) if @path.match(%r{\.(#{extensions})\z})
75
+ when String
76
+ # Folder
77
+ path = ::Rack::Utils.unescape(@path)
78
+ set_header(result) if
79
+ (path.start_with?(rule) || path.start_with?('/' + rule))
80
+ else
81
+ end
82
+ end
83
+ end
84
+
85
+ def set_header(result)
86
+ result.each do |field, content|
87
+ @headers[field] = content
88
+ end
89
+ end
90
+
91
+ end
92
+ end
@@ -0,0 +1,6 @@
1
+ require 'butler'
2
+
3
+ module Butler
4
+ class Railtie < Rails::Railtie
5
+ end
6
+ end
@@ -0,0 +1,97 @@
1
+ require 'rack/utils'
2
+ require 'butler/handler'
3
+
4
+ module Butler
5
+ #
6
+ # Butler::Static
7
+ #
8
+ # is a Static Web Server based on ActionDispatch::Static.
9
+ # It is intended to be used with Rails in an environment
10
+ # where serving static assets through a specialized web
11
+ # server such as nginx is technically not an option.
12
+ #
13
+ # Butler::Static extends ActionDispatch::Static's func-
14
+ # tionality to allow a the developer to set custom rules
15
+ # for the HTTP headers that the files sent should carry.
16
+ #
17
+ # The author's intent was to allow Rails to serve static
18
+ # assets with custom HTTP Headers.
19
+ # This allows for example serving webfonts or icon fonts
20
+ # carrying the appropriate HTTP headers via a Content
21
+ # Delivery Network (CDN) such as Amazon's Cloudfront.
22
+ # Files requested by the CDN will need to carry the headers
23
+ # the file should be sent with to the visitor's browser
24
+ # already when being sent from the web server.
25
+ #
26
+ # ActionDispatch::Static, Rack::Static et al. don't allow
27
+ # (yet) to add custom HTTP headers to files because the
28
+ # underlying Rack::File implementation only allows for
29
+ # setting a static 'Cache-Control' header.
30
+ #
31
+ # Code adapted from Rails' ActionDispatch::Static.
32
+ #
33
+ # If no matching file can be found in the precompiled assets
34
+ # the request will bubble up to the Rails stack to decide
35
+ # how to handle it
36
+ #
37
+ # Usage:
38
+ # config.middleware.delete ActionDispatch::Static
39
+ # config.middleware.insert_before Rack::Cache, Butler::Static
40
+ #
41
+ # # Provide the header rules like so
42
+ # config.assets.header_rules = {
43
+ # rule => { header_field => content },
44
+ # another_rule => { header_field => content }
45
+ # }
46
+ #
47
+ # # These rules are shipped along
48
+ # # 1) Global
49
+ # :global => Matches every file
50
+ #
51
+ # # 2) Folders
52
+ # '/folder' => Matches all files in a certain folder
53
+ # '/folder/subfolder' => ...
54
+ # Note: Provide the folder as a string,
55
+ # with or without the starting slash
56
+ #
57
+ # # 3) File Extensions
58
+ # ['css', 'js'] => Will match all files ending in .css or .js
59
+ # %w(css js) => ...
60
+ # Note: Provide the file extensions in an array,
61
+ # use any ruby syntax you like to set that array up
62
+ #
63
+ # # 4) Regular Expressions / Regexp
64
+ # %r{\.(?:css|js)\z} => will match all files ending in .css or .js
65
+ # /\.(?:eot|ttf|otf|woff|svg)\z/ => will match all files ending
66
+ # in the most common web font formats
67
+ #
68
+ # # 5) Shortcuts
69
+ # There is currently only one shortcut defined.
70
+ # :fonts => will match all files ending in eot, ttf, otf, woff, svg
71
+ # using the Regexp stated above
72
+ #
73
+ # Note: The rules will be applied in the order the are listed,
74
+ # thus more special rules further down below can override
75
+ # general global HTTP header settings
76
+ #
77
+ class Static
78
+ def initialize(app, path, options={})
79
+ @app = app
80
+ @header_rules = options[:header_rules] || {}
81
+ @file_handler = Butler::Handler.new(path, header_rules: @header_rules)
82
+ end
83
+
84
+ def call(env)
85
+ case env['REQUEST_METHOD']
86
+ when 'GET', 'HEAD'
87
+ path = env['PATH_INFO'].chomp('/')
88
+ if match = @file_handler.match?(path)
89
+ env['PATH_INFO'] = match
90
+ return @file_handler.call(env)
91
+ end
92
+ end
93
+
94
+ @app.call(env)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,3 @@
1
+ module Butler
2
+ VERSION = "0.0.3"
3
+ end
data/lib/butler.rb ADDED
@@ -0,0 +1,9 @@
1
+ require 'butler/version'
2
+ require 'butler/static'
3
+ require 'butler/handler'
4
+ require 'butler/asset'
5
+ require 'butler/railtie'
6
+
7
+ module Butler
8
+ # Butler
9
+ end
@@ -0,0 +1,226 @@
1
+ require 'minitest_helper'
2
+ require 'butler/asset'
3
+ require 'rack/mock'
4
+
5
+ describe Butler::Asset do
6
+ DOCROOT = File.expand_path(File.dirname(__FILE__))
7
+
8
+ def file(*args)
9
+ Butler::Asset.new(*args)
10
+ end
11
+
12
+ before do
13
+ @file = Rack::MockRequest.new(file(DOCROOT))
14
+ end
15
+
16
+ describe 'when serving files' do
17
+ #
18
+ # Serve Files
19
+ #
20
+ it 'should serve available files' do
21
+ res = @file.get('files/index.html')
22
+
23
+ res.status.must_equal 200
24
+ res.body.must_equal '<html>Index</html>'
25
+ end
26
+
27
+ it 'should return status 404 if file cannot be found' do
28
+ res = @file.get('files/unknown.html')
29
+
30
+ res.status.must_equal 404
31
+ end
32
+
33
+ #
34
+ # Request Methods
35
+ #
36
+ it 'should only support GET and HEAD requests' do
37
+ file = 'files/index.html'
38
+
39
+ allowed = %w(get head)
40
+ allowed.each do |method|
41
+ res = @file.send(method, file)
42
+ res.status.must_equal 200
43
+ end
44
+
45
+ forbidden = %w(post put delete) # patch is breaking
46
+ forbidden.each do |method|
47
+ res = @file.send(method, file)
48
+ res.status.must_equal 405
49
+ end
50
+ end
51
+
52
+ #
53
+ # Headers
54
+ #
55
+ it 'should set header Last-Modified' do
56
+ file = 'files/index.html'
57
+ path = File.join(DOCROOT, file)
58
+ res = @file.get(file)
59
+
60
+ res.status.must_equal 200
61
+ res['Last-Modified'].must_equal File.mtime(path).httpdate
62
+ end
63
+ it "should set Content-Length correctly for HEAD requests" do
64
+ res = @file.head('files/index.html')
65
+
66
+ res.status.must_equal 200
67
+ res['Content-Length'].must_equal '18'
68
+ end
69
+
70
+ it 'should set provided headers' do
71
+ file = Butler::Asset.new(DOCROOT,
72
+ headers: {'Cache-Control' => 'public, max-age=42'})
73
+ req = Rack::MockRequest.new(file)
74
+
75
+ res = req.get('files/index.html')
76
+
77
+ res.status.must_equal 200
78
+ res['Cache-Control'].must_equal 'public, max-age=42'
79
+ end
80
+
81
+ #
82
+ # Modified / unmodified Files (304 / 200)
83
+ #
84
+ it 'should return status 304 if file is not modified since last serve' do
85
+ file = 'files/index.html'
86
+ path = File.join(DOCROOT, file)
87
+ time = File.mtime(path).httpdate
88
+ res = @file.get(file, 'HTTP_IF_MODIFIED_SINCE' => time)
89
+
90
+ res.status.must_equal 304
91
+ res.body.must_be_empty
92
+ end
93
+
94
+ it 'should serve the file if it is modified since last serve' do
95
+ file = 'files/index.html'
96
+ path = File.join(DOCROOT, file)
97
+ time = (File.mtime(path) - 60).httpdate
98
+ res = @file.get(file, 'HTTP_IF_MODIFIED_SINCE' => time)
99
+
100
+ res.status.must_equal 200
101
+ res.body.must_equal '<html>Index</html>'
102
+ end
103
+
104
+ #
105
+ # URL-encoded filenames
106
+ #
107
+ it 'should serve files with URL encoded filenames' do
108
+ res = @file.get('files/%69%6E%64%65%78%2Ehtml')
109
+
110
+ res.status.must_equal 200
111
+ res.body.must_equal '<html>Index</html>'
112
+ end
113
+
114
+ it 'should allow safe directory traversal' do
115
+ res = @file.get('files/../asset_spec.rb')
116
+ res.status.must_equal 200
117
+
118
+ res = @file.get('files/../files/index.html')
119
+ res.status.must_equal 200
120
+
121
+ res = @file.get('.')
122
+ res.status.must_equal 404
123
+
124
+ res = @file.get('files/..')
125
+ res.status.must_equal 404
126
+ end
127
+
128
+ #
129
+ # Directory Traversal
130
+ #
131
+ it 'should not allow unsafe directory traversal' do
132
+ res = @file.get('../minitest_helper.rb')
133
+ res.status.must_equal 404
134
+
135
+ res = @file.get('/../minitest_helper.rb')
136
+ res.status.must_equal 404
137
+
138
+ res = @file.get('../butler/files/index.html')
139
+ res.status.must_equal 404
140
+
141
+ # Ensure file existance
142
+ res = @file.get('files/index.html')
143
+ res.status.must_equal 200
144
+ end
145
+
146
+ it 'should allow safe directory traversal with encoded periods' do
147
+ res = @file.get('files/%2E%2E/asset_spec.rb')
148
+ res.status.must_equal 200
149
+
150
+ res = @file.get('files/%2E%2E/files/index.html')
151
+ res.status.must_equal 200
152
+
153
+ res = @file.get('%2E')
154
+ res.status.must_equal 404
155
+
156
+ res = @file.get('files/%2E')
157
+ res.status.must_equal 404
158
+ end
159
+
160
+ it 'should not allow unsafe directory traversal with encoded periods' do
161
+ res = @file.get('%2E%2E/minitest_helper.rb')
162
+ res.status.must_equal 404
163
+
164
+ res = @file.get('/%2E%2E/minitest_helper.rb')
165
+ res.status.must_equal 404
166
+ end
167
+
168
+ it 'should allow files with .. in their name' do
169
+ res = @file.get('files/..test')
170
+ res.status.must_equal 404
171
+
172
+ res = @file.get('files/test..')
173
+ res.status.must_equal 404
174
+
175
+ res = @file.get('files../test..')
176
+ res.status.must_equal 404
177
+ end
178
+
179
+ it 'should detect SystemCallErrors' do
180
+ res = @file.get('/cgi')
181
+ res.status.must_equal 404
182
+
183
+ res = @file.get('cgi')
184
+ res.status.must_equal 404
185
+
186
+ res = @file.get('/files')
187
+ res.status.must_equal 404
188
+
189
+ res = @file.get('files')
190
+ res.status.must_equal 404
191
+ end
192
+
193
+ it 'should return bodies that respond to #to_path' do
194
+ file = 'files/index.html'
195
+ path = File.join(DOCROOT, file)
196
+ env = Rack::MockRequest.env_for(file)
197
+ status, _, body = Butler::Asset.new(DOCROOT).call(env)
198
+
199
+ status.must_equal 200
200
+ body.must_respond_to :to_path
201
+ body.to_path.must_equal path
202
+ end
203
+
204
+ #
205
+ # Byte Ranges
206
+ #
207
+ it 'should return correct byte range in body' do
208
+ res = @file.get('files/index.html', 'HTTP_RANGE' => 'bytes=2-12')
209
+
210
+ res.status.must_equal 206
211
+ res.body.must_equal 'tml>Index</'
212
+
213
+ res['Content-Length'].must_equal '11'
214
+ # before: res['Content-Range'].must_equal 'bytes=2-12'
215
+ res['Content-Range'].must_equal 'bytes 2-12/18'
216
+ end
217
+
218
+ it 'should return error for unsatisfiable byte range' do
219
+ res = @file.get('files/index.html', 'HTTP_RANGE' => 'bytes=1234-5678')
220
+
221
+ res.status.must_equal 416
222
+ res['Content-Range'].must_equal 'bytes */18'
223
+ end
224
+
225
+ end
226
+ end
@@ -0,0 +1 @@
1
+ <html>Index</html>
@@ -0,0 +1,17 @@
1
+ require 'minitest/autorun'
2
+ require 'turn'
3
+
4
+ Turn.config do |c|
5
+ # use one of output formats:
6
+ # :outline - turn's original case/test outline mode [default]
7
+ # :progress - indicates progress with progress bar
8
+ # :dotted - test/unit's traditional dot-progress mode
9
+ # :pretty - new pretty reporter
10
+ # :marshal - dump output as YAML (normal run mode only)
11
+ # :cue - interactive testing
12
+ c.format = :pretty
13
+ # turn on invoke/execute tracing, enable full backtrace
14
+ c.trace = false
15
+ # use humanized test names (works only with :outline format)
16
+ c.natural = true
17
+ end
@@ -0,0 +1 @@
1
+ absolute/*
@@ -0,0 +1 @@
1
+ 404 error fixture
@@ -0,0 +1 @@
1
+ 500 localized error fixture
@@ -0,0 +1 @@
1
+ 500 error fixture
@@ -0,0 +1 @@
1
+ // cools.js
@@ -0,0 +1 @@
1
+ /*file.css*/
@@ -0,0 +1 @@
1
+ /foo/bar.html
@@ -0,0 +1,3 @@
1
+ body {
2
+ background: #000;
3
+ }
@@ -0,0 +1 @@
1
+ /foo/index.html
@@ -0,0 +1 @@
1
+ /index.html
@@ -0,0 +1 @@
1
+ // application js
@@ -0,0 +1 @@
1
+ // bank js
@@ -0,0 +1 @@
1
+ // common.javascript
@@ -0,0 +1 @@
1
+ // controls js
@@ -0,0 +1 @@
1
+ // dragdrop js
@@ -0,0 +1 @@
1
+ // effects js
@@ -0,0 +1 @@
1
+ // prototype js
@@ -0,0 +1 @@
1
+ // robber js
@@ -0,0 +1 @@
1
+ // subdir js
@@ -0,0 +1 @@
1
+ // version.1.0 js
@@ -0,0 +1 @@
1
+ /* bank.css */
@@ -0,0 +1 @@
1
+ /* random.styles */
@@ -0,0 +1 @@
1
+ /* robber.css */
@@ -0,0 +1 @@
1
+ /* subdir.css */
@@ -0,0 +1 @@
1
+ /* version.1.0.css */
@@ -0,0 +1 @@
1
+ /* Webfont */
@@ -0,0 +1,195 @@
1
+ # encoding: utf-8
2
+ require 'minitest/autorun'
3
+ require 'butler/static'
4
+ require 'rack/mock'
5
+ require 'action_controller'
6
+
7
+ FIXTURE_LOAD_PATH = File.join(File.dirname(__FILE__), 'fixtures')
8
+
9
+ module StaticTests
10
+ def test_serves_dynamic_content
11
+ assert_equal "Hello, World!", get("/nofile").body
12
+ end
13
+
14
+ def test_handles_urls_with_bad_encoding
15
+ assert_equal "Hello, World!", get("/doorkeeper%E3E4").body
16
+ end
17
+
18
+ def test_serves_static_index_at_root
19
+ assert_html "/index.html", get("/index.html")
20
+ assert_html "/index.html", get("/index")
21
+ assert_html "/index.html", get("/")
22
+ assert_html "/index.html", get("")
23
+ end
24
+
25
+ def test_serves_static_file_in_directory
26
+ assert_html "/foo/bar.html", get("/foo/bar.html")
27
+ assert_html "/foo/bar.html", get("/foo/bar/")
28
+ assert_html "/foo/bar.html", get("/foo/bar")
29
+ end
30
+
31
+ def test_serves_static_index_file_in_directory
32
+ assert_html "/foo/index.html", get("/foo/index.html")
33
+ assert_html "/foo/index.html", get("/foo/")
34
+ assert_html "/foo/index.html", get("/foo")
35
+ end
36
+
37
+ def test_served_static_file_with_non_english_filename
38
+ assert_html "means hello in Japanese\n", get("/foo/#{Rack::Utils.escape("こんにちは.html")}")
39
+ end
40
+
41
+
42
+ def test_serves_static_file_with_exclamation_mark_in_filename
43
+ with_static_file "/foo/foo!bar.html" do |file|
44
+ assert_html file, get("/foo/foo%21bar.html")
45
+ assert_html file, get("/foo/foo!bar.html")
46
+ end
47
+ end
48
+
49
+ def test_serves_static_file_with_dollar_sign_in_filename
50
+ with_static_file "/foo/foo$bar.html" do |file|
51
+ assert_html file, get("/foo/foo%24bar.html")
52
+ assert_html file, get("/foo/foo$bar.html")
53
+ end
54
+ end
55
+
56
+ def test_serves_static_file_with_ampersand_in_filename
57
+ with_static_file "/foo/foo&bar.html" do |file|
58
+ assert_html file, get("/foo/foo%26bar.html")
59
+ assert_html file, get("/foo/foo&bar.html")
60
+ end
61
+ end
62
+
63
+ def test_serves_static_file_with_apostrophe_in_filename
64
+ with_static_file "/foo/foo'bar.html" do |file|
65
+ assert_html file, get("/foo/foo%27bar.html")
66
+ assert_html file, get("/foo/foo'bar.html")
67
+ end
68
+ end
69
+
70
+ def test_serves_static_file_with_parentheses_in_filename
71
+ with_static_file "/foo/foo(bar).html" do |file|
72
+ assert_html file, get("/foo/foo%28bar%29.html")
73
+ assert_html file, get("/foo/foo(bar).html")
74
+ end
75
+ end
76
+
77
+ def test_serves_static_file_with_plus_sign_in_filename
78
+ with_static_file "/foo/foo+bar.html" do |file|
79
+ assert_html file, get("/foo/foo%2Bbar.html")
80
+ assert_html file, get("/foo/foo+bar.html")
81
+ end
82
+ end
83
+
84
+ def test_serves_static_file_with_comma_in_filename
85
+ with_static_file "/foo/foo,bar.html" do |file|
86
+ assert_html file, get("/foo/foo%2Cbar.html")
87
+ assert_html file, get("/foo/foo,bar.html")
88
+ end
89
+ end
90
+
91
+ def test_serves_static_file_with_semi_colon_in_filename
92
+ with_static_file "/foo/foo;bar.html" do |file|
93
+ assert_html file, get("/foo/foo%3Bbar.html")
94
+ assert_html file, get("/foo/foo;bar.html")
95
+ end
96
+ end
97
+
98
+ def test_serves_static_file_with_at_symbol_in_filename
99
+ with_static_file "/foo/foo@bar.html" do |file|
100
+ assert_html file, get("/foo/foo%40bar.html")
101
+ assert_html file, get("/foo/foo@bar.html")
102
+ end
103
+ end
104
+
105
+ # Windows doesn't allow \ / : * ? " < > | in filenames
106
+ unless RbConfig::CONFIG['host_os'] =~ /mswin|mingw/
107
+ def test_serves_static_file_with_colon
108
+ with_static_file "/foo/foo:bar.html" do |file|
109
+ assert_html file, get("/foo/foo%3Abar.html")
110
+ assert_html file, get("/foo/foo:bar.html")
111
+ end
112
+ end
113
+
114
+ def test_serves_static_file_with_asterisk
115
+ with_static_file "/foo/foo*bar.html" do |file|
116
+ assert_html file, get("/foo/foo%2Abar.html")
117
+ assert_html file, get("/foo/foo*bar.html")
118
+ end
119
+ end
120
+ end
121
+
122
+ #
123
+ # Headers
124
+ #
125
+ def test_sets_headers_global_via_global_shortcut
126
+ response = get("/index.html")
127
+ assert_html "/index.html", response
128
+ assert_equal "public, max-age=100", response.headers["Cache-Control"]
129
+ end
130
+
131
+ def test_sets_headers_fonts_via_fonts_shortcut
132
+ response = get("/stylesheets/webfont.eot")
133
+ assert_equal "public, max-age=200", response.headers["Cache-Control"]
134
+ end
135
+
136
+ def test_set_headers_for_file_extensions_via_array
137
+ response = get("/images/rails.png")
138
+ assert_equal "public, max-age=300", response.headers["Cache-Control"]
139
+ end
140
+
141
+ def test_set_headers_for_folder_via_string
142
+ response = get("/javascripts/subdir/subdir.js")
143
+ assert_equal "public, max-age=400", response.headers["Cache-Control"]
144
+
145
+ response = get("/foo/bar.html")
146
+ assert_equal "public, max-age=500", response.headers["Cache-Control"]
147
+ end
148
+
149
+ def test_set_headers_flexibely_via_regexp
150
+ response = get("/elsewhere/file.css")
151
+ assert_equal "public, max-age=600", response.headers["Cache-Control"]
152
+ end
153
+
154
+ private
155
+
156
+ def assert_html(body, response)
157
+ assert_equal body, response.body
158
+ assert_equal "text/html", response.headers["Content-Type"]
159
+ end
160
+
161
+ def get(path)
162
+ Rack::MockRequest.new(@app).request("GET", path)
163
+ end
164
+
165
+ def with_static_file(file)
166
+ path = "#{FIXTURE_LOAD_PATH}/public" + file
167
+ File.open(path, "wb+") { |f| f.write(file) }
168
+ yield file
169
+ ensure
170
+ File.delete(path)
171
+ end
172
+ end
173
+
174
+ class StaticTest < MiniTest::Unit::TestCase
175
+ DummyApp = lambda { |env|
176
+ [200, {"Content-Type" => "text/plain"}, ["Hello, World!"]]
177
+ }
178
+
179
+ header_rules = {
180
+ :global => {'Cache-Control' => 'public, max-age=100'},
181
+ :fonts => {'Cache-Control' => 'public, max-age=200'},
182
+ %w(png) => {'Cache-Control' => 'public, max-age=300'},
183
+ 'javascripts/subdir' => {'Cache-Control' => 'public, max-age=400'},
184
+ '/foo' => {'Cache-Control' => 'public, max-age=500'},
185
+ /\.(css|erb)\z/ => {'Cache-Control' => 'public, max-age=600'},
186
+ }
187
+
188
+ App = Butler::Static.new(DummyApp, "#{FIXTURE_LOAD_PATH}/public", header_rules: header_rules)
189
+
190
+ def setup
191
+ @app = App
192
+ end
193
+
194
+ include StaticTests
195
+ end
metadata ADDED
@@ -0,0 +1,221 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: butler_static
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Thomas Klemm
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-08-19 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: rack
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: minitest
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: guard-minitest
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: turn
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :development
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ - !ruby/object:Gem::Dependency
79
+ name: activesupport
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ~>
84
+ - !ruby/object:Gem::Version
85
+ version: 3.2.8
86
+ type: :development
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ~>
92
+ - !ruby/object:Gem::Version
93
+ version: 3.2.8
94
+ - !ruby/object:Gem::Dependency
95
+ name: actionpack
96
+ requirement: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ~>
100
+ - !ruby/object:Gem::Version
101
+ version: 3.2.8
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ~>
108
+ - !ruby/object:Gem::Version
109
+ version: 3.2.8
110
+ description: Butler is a Rack Middleware that serves static assets for your Rails
111
+ app.
112
+ email:
113
+ - github@tklemm.eu
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - .gitignore
119
+ - Gemfile
120
+ - Guardfile
121
+ - LICENSE.txt
122
+ - README.md
123
+ - Rakefile
124
+ - butler.gemspec
125
+ - lib/butler.rb
126
+ - lib/butler/asset.rb
127
+ - lib/butler/handler.rb
128
+ - lib/butler/railtie.rb
129
+ - lib/butler/static.rb
130
+ - lib/butler/version.rb
131
+ - spec/butler/asset_spec.rb
132
+ - spec/butler/files/index.html
133
+ - spec/minitest_helper.rb
134
+ - test/butler/fixtures/public/.gitignore
135
+ - test/butler/fixtures/public/404.html
136
+ - test/butler/fixtures/public/500.da.html
137
+ - test/butler/fixtures/public/500.html
138
+ - test/butler/fixtures/public/elsewhere/cools.js
139
+ - test/butler/fixtures/public/elsewhere/file.css
140
+ - test/butler/fixtures/public/foo/bar.html
141
+ - test/butler/fixtures/public/foo/baz.css
142
+ - test/butler/fixtures/public/foo/index.html
143
+ - test/butler/fixtures/public/foo/こんにちは.html
144
+ - test/butler/fixtures/public/images/rails.png
145
+ - test/butler/fixtures/public/index.html
146
+ - test/butler/fixtures/public/javascripts/application.js
147
+ - test/butler/fixtures/public/javascripts/bank.js
148
+ - test/butler/fixtures/public/javascripts/common.javascript
149
+ - test/butler/fixtures/public/javascripts/controls.js
150
+ - test/butler/fixtures/public/javascripts/dragdrop.js
151
+ - test/butler/fixtures/public/javascripts/effects.js
152
+ - test/butler/fixtures/public/javascripts/prototype.js
153
+ - test/butler/fixtures/public/javascripts/robber.js
154
+ - test/butler/fixtures/public/javascripts/subdir/subdir.js
155
+ - test/butler/fixtures/public/javascripts/version.1.0.js
156
+ - test/butler/fixtures/public/stylesheets/bank.css
157
+ - test/butler/fixtures/public/stylesheets/random.styles
158
+ - test/butler/fixtures/public/stylesheets/robber.css
159
+ - test/butler/fixtures/public/stylesheets/subdir/subdir.css
160
+ - test/butler/fixtures/public/stylesheets/version.1.0.css
161
+ - test/butler/fixtures/public/stylesheets/webfont.eot
162
+ - test/butler/static_test.rb
163
+ homepage: https://github.com/thomasklemm/butler
164
+ licenses: []
165
+ post_install_message:
166
+ rdoc_options: []
167
+ require_paths:
168
+ - lib
169
+ required_ruby_version: !ruby/object:Gem::Requirement
170
+ none: false
171
+ requirements:
172
+ - - ! '>='
173
+ - !ruby/object:Gem::Version
174
+ version: '0'
175
+ required_rubygems_version: !ruby/object:Gem::Requirement
176
+ none: false
177
+ requirements:
178
+ - - ! '>='
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ requirements: []
182
+ rubyforge_project:
183
+ rubygems_version: 1.8.23
184
+ signing_key:
185
+ specification_version: 3
186
+ summary: Butler is a Rack Middleware that serves static assets for your Rails app.
187
+ It allows you to set HTTP headers for individual files or folders based on rules.
188
+ test_files:
189
+ - spec/butler/asset_spec.rb
190
+ - spec/butler/files/index.html
191
+ - spec/minitest_helper.rb
192
+ - test/butler/fixtures/public/.gitignore
193
+ - test/butler/fixtures/public/404.html
194
+ - test/butler/fixtures/public/500.da.html
195
+ - test/butler/fixtures/public/500.html
196
+ - test/butler/fixtures/public/elsewhere/cools.js
197
+ - test/butler/fixtures/public/elsewhere/file.css
198
+ - test/butler/fixtures/public/foo/bar.html
199
+ - test/butler/fixtures/public/foo/baz.css
200
+ - test/butler/fixtures/public/foo/index.html
201
+ - test/butler/fixtures/public/foo/こんにちは.html
202
+ - test/butler/fixtures/public/images/rails.png
203
+ - test/butler/fixtures/public/index.html
204
+ - test/butler/fixtures/public/javascripts/application.js
205
+ - test/butler/fixtures/public/javascripts/bank.js
206
+ - test/butler/fixtures/public/javascripts/common.javascript
207
+ - test/butler/fixtures/public/javascripts/controls.js
208
+ - test/butler/fixtures/public/javascripts/dragdrop.js
209
+ - test/butler/fixtures/public/javascripts/effects.js
210
+ - test/butler/fixtures/public/javascripts/prototype.js
211
+ - test/butler/fixtures/public/javascripts/robber.js
212
+ - test/butler/fixtures/public/javascripts/subdir/subdir.js
213
+ - test/butler/fixtures/public/javascripts/version.1.0.js
214
+ - test/butler/fixtures/public/stylesheets/bank.css
215
+ - test/butler/fixtures/public/stylesheets/random.styles
216
+ - test/butler/fixtures/public/stylesheets/robber.css
217
+ - test/butler/fixtures/public/stylesheets/subdir/subdir.css
218
+ - test/butler/fixtures/public/stylesheets/version.1.0.css
219
+ - test/butler/fixtures/public/stylesheets/webfont.eot
220
+ - test/butler/static_test.rb
221
+ has_rdoc: