butler_static 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
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: