utopia 0.12.6 → 1.0.0

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +6 -2
  3. data/Gemfile +6 -0
  4. data/README.md +48 -14
  5. data/Rakefile +5 -0
  6. data/bin/utopia +132 -15
  7. data/lib/utopia.rb +13 -10
  8. data/lib/utopia/content.rb +140 -0
  9. data/lib/utopia/content/link.rb +124 -0
  10. data/lib/utopia/content/links.rb +228 -0
  11. data/lib/utopia/content/node.rb +387 -0
  12. data/lib/utopia/content/processor.rb +128 -0
  13. data/lib/utopia/content/tag.rb +102 -0
  14. data/lib/utopia/controller.rb +137 -0
  15. data/lib/utopia/controller/action.rb +112 -0
  16. data/lib/utopia/controller/base.rb +174 -0
  17. data/lib/utopia/{middleware/controller → controller}/variables.rb +36 -38
  18. data/lib/utopia/exception_handler.rb +79 -0
  19. data/lib/utopia/extensions/array.rb +2 -2
  20. data/lib/utopia/localization.rb +143 -0
  21. data/lib/utopia/mail_exceptions.rb +136 -0
  22. data/lib/utopia/middleware.rb +7 -22
  23. data/lib/utopia/path.rb +150 -60
  24. data/lib/utopia/redirector.rb +152 -0
  25. data/lib/utopia/{extensions/hash.rb → session.rb} +4 -6
  26. data/lib/utopia/session/encrypted_cookie.rb +46 -48
  27. data/lib/utopia/{middleware/directory_index.rb → session/lazy_hash.rb} +44 -27
  28. data/lib/utopia/static.rb +255 -0
  29. data/lib/utopia/tags/deferred.rb +12 -8
  30. data/lib/utopia/tags/environment.rb +18 -6
  31. data/lib/utopia/tags/node.rb +12 -8
  32. data/lib/utopia/tags/override.rb +12 -12
  33. data/lib/utopia/version.rb +1 -1
  34. data/setup/.bowerrc +3 -0
  35. data/{lib/utopia/setup → setup}/Gemfile +1 -1
  36. data/setup/Rakefile +4 -0
  37. data/{lib/utopia/setup → setup}/cache/head/readme.txt +0 -0
  38. data/{lib/utopia/setup → setup}/cache/meta/readme.txt +0 -0
  39. data/setup/config.ru +64 -0
  40. data/{lib/utopia/setup → setup}/lib/readme.txt +0 -0
  41. data/{lib/utopia/setup → setup}/pages/_heading.xnode +0 -0
  42. data/{lib/utopia/setup → setup}/pages/_page.xnode +1 -1
  43. data/{lib/utopia/setup → setup}/pages/_static/icon.png +0 -0
  44. data/setup/pages/_static/site.css +70 -0
  45. data/{lib/utopia/setup → setup}/pages/errors/exception.xnode +0 -0
  46. data/{lib/utopia/setup → setup}/pages/errors/file-not-found.xnode +0 -0
  47. data/{lib/utopia/setup → setup}/pages/links.yaml +0 -0
  48. data/setup/pages/welcome/index.xnode +17 -0
  49. data/{lib/utopia/setup → setup}/public/readme.txt +0 -0
  50. data/spec/utopia/content/link_spec.rb +108 -0
  51. data/spec/utopia/content/links/foo/index.xnode +0 -0
  52. data/spec/utopia/content/links/foo/links.yaml +2 -0
  53. data/spec/utopia/content/links/foo/test.de.xnode +0 -0
  54. data/spec/utopia/content/links/foo/test.en.xnode +0 -0
  55. data/spec/utopia/content/links/links.yaml +9 -0
  56. data/spec/utopia/content/links/welcome.xnode +0 -0
  57. data/spec/utopia/content/localized/five/index.en.xnode +0 -0
  58. data/spec/utopia/content/localized/four/index.en.xnode +0 -0
  59. data/spec/utopia/content/localized/four/index.zh.xnode +0 -0
  60. data/spec/utopia/content/localized/four/links.yaml +4 -0
  61. data/spec/utopia/content/localized/links.yaml +16 -0
  62. data/spec/utopia/content/localized/one.xnode +0 -0
  63. data/spec/utopia/content/localized/three/index.xnode +0 -0
  64. data/spec/utopia/content/localized/two.en.xnode +0 -0
  65. data/spec/utopia/content/localized/two.zh.xnode +0 -0
  66. data/spec/utopia/content/node/ordered/first.xnode +0 -0
  67. data/spec/utopia/content/node/ordered/index.xnode +0 -0
  68. data/spec/utopia/content/node/ordered/links.yaml +4 -0
  69. data/spec/utopia/content/node/ordered/second.xnode +0 -0
  70. data/spec/utopia/content/node/related/foo.en.xnode +0 -0
  71. data/spec/utopia/content/node/related/foo.ja.xnode +0 -0
  72. data/spec/utopia/content/node/related/links.yaml +4 -0
  73. data/spec/utopia/content/node_spec.rb +63 -0
  74. data/spec/utopia/{middleware/content_spec.rb → content/processor_spec.rb} +34 -23
  75. data/spec/utopia/content_spec.rb +87 -0
  76. data/spec/utopia/content_spec.ru +10 -0
  77. data/spec/utopia/{middleware/controller_spec.rb → controller_spec.rb} +61 -16
  78. data/spec/utopia/controller_spec.ru +4 -0
  79. data/spec/utopia/extensions_spec.rb +6 -17
  80. data/spec/utopia/localization_spec.rb +60 -0
  81. data/spec/utopia/localization_spec.ru +11 -0
  82. data/{lib/utopia/tags.rb → spec/utopia/middleware_spec.rb} +8 -14
  83. data/spec/utopia/{middleware/content_root → pages}/_heading.xnode +0 -0
  84. data/spec/utopia/pages/content/_show-value.xnode +1 -0
  85. data/spec/utopia/pages/content/test-partial.xnode +1 -0
  86. data/spec/utopia/pages/controller/controller.rb +28 -0
  87. data/spec/utopia/pages/controller/index.xnode +1 -0
  88. data/spec/utopia/pages/controller/nested/controller.rb +4 -0
  89. data/spec/utopia/{middleware/content_root → pages}/index.xnode +0 -0
  90. data/spec/utopia/pages/localized.de.txt +1 -0
  91. data/spec/utopia/pages/localized.en.txt +1 -0
  92. data/spec/utopia/pages/localized.jp.txt +1 -0
  93. data/spec/utopia/pages/node/index.xnode +1 -0
  94. data/spec/utopia/pages/test.txt +1 -0
  95. data/spec/utopia/path_spec.rb +109 -0
  96. data/spec/utopia/rack_spec.rb +2 -0
  97. data/spec/utopia/session_spec.rb +82 -0
  98. data/spec/utopia/session_spec.ru +20 -0
  99. data/spec/utopia/spec_helper.rb +16 -0
  100. data/{lib/utopia/extensions/string.rb → spec/utopia/static_spec.rb} +24 -15
  101. data/spec/utopia/static_spec.ru +4 -0
  102. data/utopia.gemspec +3 -3
  103. metadata +138 -54
  104. data/lib/utopia/extensions/regexp.rb +0 -33
  105. data/lib/utopia/link.rb +0 -288
  106. data/lib/utopia/middleware/all.rb +0 -33
  107. data/lib/utopia/middleware/content.rb +0 -157
  108. data/lib/utopia/middleware/content/node.rb +0 -386
  109. data/lib/utopia/middleware/content/processor.rb +0 -123
  110. data/lib/utopia/middleware/controller.rb +0 -130
  111. data/lib/utopia/middleware/controller/action.rb +0 -121
  112. data/lib/utopia/middleware/controller/base.rb +0 -184
  113. data/lib/utopia/middleware/exception_handler.rb +0 -80
  114. data/lib/utopia/middleware/localization.rb +0 -147
  115. data/lib/utopia/middleware/localization/name.rb +0 -69
  116. data/lib/utopia/middleware/mail_exceptions.rb +0 -138
  117. data/lib/utopia/middleware/redirector.rb +0 -146
  118. data/lib/utopia/middleware/requester.rb +0 -126
  119. data/lib/utopia/middleware/static.rb +0 -295
  120. data/lib/utopia/setup.rb +0 -60
  121. data/lib/utopia/setup/config.ru +0 -47
  122. data/lib/utopia/setup/pages/_static/background.png +0 -0
  123. data/lib/utopia/setup/pages/_static/site.css +0 -48
  124. data/lib/utopia/setup/pages/welcome/index.xnode +0 -7
  125. data/lib/utopia/tag.rb +0 -105
  126. data/lib/utopia/tags/all.rb +0 -34
@@ -0,0 +1,152 @@
1
+ # Copyright, 2012, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require_relative 'middleware'
22
+
23
+ module Utopia
24
+ class FailedRequestError < StandardError
25
+ def initialize(resource_path, resource_status, error_path, error_status)
26
+ @resource_path = resource_path
27
+ @resource_status = resource_status
28
+
29
+ @error_path = error_path
30
+ @error_status = error_status
31
+ end
32
+
33
+ def to_s
34
+ "Requested resource #{@resource_path} resulted in a #{@resource_status} error. Requested error handler #{@error_path} resulted in a #{@error_status} error."
35
+ end
36
+ end
37
+
38
+ class Redirector
39
+ # This redirects directories to the directory + 'index'
40
+ DIRECTORY_INDEX = [/^(.*)\/$/, lambda{|prefix| [307, {"Location" => "#{prefix}index"}, []]}]
41
+
42
+ # Redirects a whole source tree to a destination tree, given by the roots.
43
+ def self.moved(source_root, destination_root)
44
+ return [
45
+ /^#{Regexp.escape(source_root)}(.*)$/,
46
+ lambda do |match|
47
+ [301, {"Location" => (destination_root + match[1]).to_s}, []]
48
+ end
49
+ ]
50
+ end
51
+
52
+ def self.starts_with(source_root, destination_uri)
53
+ return [
54
+ /^#{Regexp.escape(source_root)}/,
55
+ destination_uri
56
+ ]
57
+ end
58
+
59
+ private
60
+
61
+ def normalize_strings(strings)
62
+ normalized = {}
63
+
64
+ strings.each_pair do |key, value|
65
+ if Array === key
66
+ key.each { |s| normalized[s] = value }
67
+ else
68
+ normalized[key] = value
69
+ end
70
+ end
71
+
72
+ return normalized
73
+ end
74
+
75
+ def normalize_patterns(patterns)
76
+ normalized = []
77
+
78
+ patterns.each do |pattern|
79
+ uri = pattern.pop
80
+
81
+ pattern.each do |key|
82
+ normalized.push([key, uri])
83
+ end
84
+ end
85
+
86
+ return normalized
87
+ end
88
+
89
+ public
90
+
91
+ def initialize(app, options = {})
92
+ @app = app
93
+
94
+ @strings = options[:strings] || {}
95
+ @patterns = options[:patterns] || []
96
+
97
+ @patterns.collect! do |rule|
98
+ if Symbol === rule[0]
99
+ self.class.send(*rule)
100
+ else
101
+ rule
102
+ end
103
+ end
104
+
105
+ @strings = normalize_strings(@strings)
106
+ @patterns = normalize_patterns(@patterns)
107
+
108
+ @errors = options[:errors]
109
+ end
110
+
111
+ def redirect(uri, match_data)
112
+ if uri.respond_to? :call
113
+ return uri.call(match_data)
114
+ else
115
+ return [301, {"Location" => uri.to_s}, []]
116
+ end
117
+ end
118
+
119
+ def call(env)
120
+ base_path = env['PATH_INFO']
121
+
122
+ if uri = @strings[base_path]
123
+ return redirect(@strings[base_path], base_path)
124
+ end
125
+
126
+ @patterns.each do |pattern, uri|
127
+ if match_data = pattern.match(base_path)
128
+ result = redirect(uri, match_data)
129
+
130
+ return result if result != nil
131
+ end
132
+ end
133
+
134
+ response = @app.call(env)
135
+
136
+ if @errors && response[0] >= 400 && uri = @errors[response[0]]
137
+ error_request = env.merge("PATH_INFO" => uri, "REQUEST_METHOD" => "GET")
138
+ error_response = @app.call(error_request)
139
+
140
+ if error_response[0] >= 400
141
+ raise FailedRequestError.new(env['PATH_INFO'], response[0], uri, error_response[0])
142
+ else
143
+ # Feed the error code back with the error document
144
+ error_response[0] = response[0]
145
+ return error_response
146
+ end
147
+ else
148
+ return response
149
+ end
150
+ end
151
+ end
152
+ end
@@ -1,4 +1,4 @@
1
- # Copyright, 2012, by Samuel G. D. Williams. <http://www.codeotaku.com>
1
+ # Copyright, 2014, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
2
  #
3
3
  # Permission is hereby granted, free of charge, to any person obtaining a copy
4
4
  # of this software and associated documentation files (the "Software"), to deal
@@ -18,10 +18,8 @@
18
18
  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
19
  # THE SOFTWARE.
20
20
 
21
- class Hash
22
- def symbolize_keys
23
- inject({}) do |options, (key, value)|
24
- options[(key.to_sym rescue key) || key] = value; options
25
- end
21
+ module Utopia
22
+ module Session
23
+ RACK_SESSION = "rack.session"
26
24
  end
27
25
  end
@@ -21,19 +21,20 @@
21
21
  require 'openssl'
22
22
  require 'digest/sha2'
23
23
 
24
+ require_relative 'lazy_hash'
25
+ require_relative '../session'
26
+
24
27
  module Utopia
25
28
  module Session
26
-
29
+ # Stores all session data client side using a private symmetric encrpytion key.
27
30
  class EncryptedCookie
28
- RACK_SESSION = "rack.session"
29
- RACK_SESSION_OPTIONS = "rack.session.options"
30
-
31
- def initialize(app, options={})
31
+ def initialize(app, options = {})
32
32
  @app = app
33
- @cookie = options[:cookie] || (RACK_SESSION + ".encrypted")
34
- @secret = Digest::SHA256.digest(options[:secret])
33
+ @cookie_name = options.delete(:cookie_name) || (RACK_SESSION + ".encrypted")
34
+
35
+ @secret = options.delete(:secret)
35
36
 
36
- @default_options = {
37
+ @options = {
37
38
  :domain => nil,
38
39
  :path => "/",
39
40
  :expires_after => nil
@@ -41,80 +42,77 @@ module Utopia
41
42
  end
42
43
 
43
44
  def call(env)
44
- original_session = load_session(env).dup
45
+ session_hash = prepare_session(env)
45
46
 
46
47
  status, headers, body = @app.call(env)
47
48
 
48
- if original_session != env[RACK_SESSION]
49
- commit_session(env, status, headers, body)
49
+ if session_hash.changed?
50
+ commit(session_hash.values, headers)
50
51
  end
51
52
 
52
53
  return [status, headers, body]
53
54
  end
54
-
55
- private
56
-
57
- def load_session(env)
58
- session = {}
59
-
55
+
56
+ protected
57
+
58
+ def prepare_session(env)
59
+ env[RACK_SESSION] = LazyHash.new do
60
+ self.load_session_values(env)
61
+ end
62
+ end
63
+
64
+ # Load session
65
+ def load_session_values(env)
66
+ values = {}
67
+
60
68
  request = Rack::Request.new(env)
61
- data = request.cookies[@cookie]
62
-
69
+ data = request.cookies[@cookie_name]
70
+
63
71
  if data
64
- session = decrypt(data) rescue session
72
+ values = decrypt(data) rescue values
65
73
  end
66
-
67
- env[RACK_SESSION] = session
68
- env[RACK_SESSION_OPTIONS] = @default_options.dup
69
-
70
- return session
74
+
75
+ return values
71
76
  end
72
77
 
73
- def commit_session(env, status, headers, body)
74
- session = env[RACK_SESSION]
75
-
76
- data = encrypt(session)
77
-
78
- if data.size > (1024 * 4)
79
- env["rack.errors"].puts "Error: #{self.class.name} data exceeds 4K. Content Dropped!"
80
- else
81
- options = env[RACK_SESSION_OPTIONS]
82
- cookie = {:value => data}
83
- cookie[:expires] = Time.now + options[:expires_after] unless options[:expires_after].nil?
84
-
85
- Rack::Utils.set_cookie_header!(headers, @cookie, cookie.merge(options))
86
- end
78
+ def commit(values, headers)
79
+ data = encrypt(values)
80
+
81
+ cookie = {:value => data}
82
+
83
+ cookie[:expires] = Time.now + @options[:expires_after] unless @options[:expires_after].nil?
84
+
85
+ Rack::Utils.set_cookie_header!(headers, @cookie_name, cookie.merge(@options))
87
86
  end
88
87
 
89
88
  def encrypt(hash)
90
89
  c = OpenSSL::Cipher::Cipher.new("aes-256-cbc")
91
90
  c.encrypt
92
-
91
+
93
92
  # your pass is what is used to encrypt/decrypt
94
93
  c.key = @secret
95
94
  c.iv = iv = c.random_iv
96
-
95
+
97
96
  e = c.update(Marshal.dump(hash))
98
97
  e << c.final
99
-
98
+
100
99
  return [iv, e].pack("m16m*")
101
100
  end
102
-
101
+
103
102
  def decrypt(data)
104
103
  iv, e = data.unpack("m16m*")
105
-
104
+
106
105
  c = OpenSSL::Cipher::Cipher.new("aes-256-cbc")
107
106
  c.decrypt
108
-
107
+
109
108
  c.key = @secret
110
109
  c.iv = iv
111
-
110
+
112
111
  d = c.update(e)
113
112
  d << c.final
114
-
113
+
115
114
  return Marshal.load(d)
116
115
  end
117
116
  end
118
-
119
117
  end
120
118
  end
@@ -1,4 +1,4 @@
1
- # Copyright, 2012, by Samuel G. D. Williams. <http://www.codeotaku.com>
1
+ # Copyright, 2014, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
2
  #
3
3
  # Permission is hereby granted, free of charge, to any person obtaining a copy
4
4
  # of this software and associated documentation files (the "Software"), to deal
@@ -18,37 +18,54 @@
18
18
  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
19
  # THE SOFTWARE.
20
20
 
21
- require 'utopia/middleware'
22
- require 'utopia/path'
21
+ require_relative '../session'
23
22
 
24
23
  module Utopia
25
- module Middleware
26
- class DirectoryIndex
27
- def initialize(app, options = {})
28
- @app = app
29
- @root = options[:root] || Utopia::Middleware::default_root
30
-
31
- @files = ["index.html"]
32
-
33
- @default = "index"
34
- end
35
-
36
- def call(env)
37
- path = Path.create(env["PATH_INFO"])
24
+ module Session
25
+ # A simple hash table which fetches it's values only when required.
26
+ class LazyHash
27
+ def initialize(&block)
28
+ @changed = false
29
+ @values = nil
38
30
 
39
- if path.directory?
40
- # Check to see if one of the files exists in the requested directory
41
- @files.each do |file|
42
- if File.exist?(File.join(@root, path.components, file))
43
- return [307, {"Location" => (path + file).to_s}, []]
44
- end
45
- end
31
+ @loader = block
32
+ end
33
+
34
+ attr :values
35
+
36
+ def [] key
37
+ load![key]
38
+ end
39
+
40
+ def []= key, value
41
+ values = load!
46
42
 
47
- # Use the default path
48
- return [307, {"Location" => (path + @default).to_s}, []]
49
- else
50
- return @app.call(env)
43
+ if values[key] != value
44
+ values[key] = value
45
+ @changed = true
51
46
  end
47
+
48
+ return value
49
+ end
50
+
51
+ def include?(key)
52
+ load!.include?(key)
53
+ end
54
+
55
+ def delete(key)
56
+ load!
57
+
58
+ @changed = true if @values.include? key
59
+
60
+ @values.delete(key)
61
+ end
62
+
63
+ def changed?
64
+ @changed
65
+ end
66
+
67
+ def load!
68
+ @values ||= @loader.call
52
69
  end
53
70
  end
54
71
  end
@@ -0,0 +1,255 @@
1
+ # Copyright, 2012, by Samuel G. D. Williams. <http://www.codeotaku.com>
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in
11
+ # all copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ # THE SOFTWARE.
20
+
21
+ require_relative 'middleware'
22
+ require_relative 'localization'
23
+
24
+ require 'time'
25
+
26
+ require 'digest/sha1'
27
+ require 'mime/types'
28
+
29
+ module Utopia
30
+ # Serve static files and include recursive name resolution using @rel@ directory entries.
31
+ class Static
32
+ MIME_TYPES = {
33
+ :xiph => {
34
+ "ogx" => "application/ogg",
35
+ "ogv" => "video/ogg",
36
+ "oga" => "audio/ogg",
37
+ "ogg" => "audio/ogg",
38
+ "spx" => "audio/ogg",
39
+ "flac" => "audio/flac",
40
+ "anx" => "application/annodex",
41
+ "axa" => "audio/annodex",
42
+ "xspf" => "application/xspf+xml",
43
+ },
44
+ :media => [
45
+ :xiph, "mp3", "mp4", "wav", "aiff", ["aac", "audio/x-aac"], "mov", "avi", "wmv", "mpg"
46
+ ],
47
+ :text => [
48
+ "html", "css", "js", ["map", "application/json"], "txt", "rtf", "xml", "pdf"
49
+ ],
50
+ :fonts => [
51
+ "otf", ["eot", "application/vnd.ms-fontobject"], "ttf", "woff"
52
+ ],
53
+ :archive => [
54
+ "zip", "tar", "tgz", "tar.gz", "tar.bz2", ["dmg", "application/x-apple-diskimage"],
55
+ ["torrent", "application/x-bittorrent"]
56
+ ],
57
+ :images => [
58
+ "png", "gif", "jpeg", "tiff", "svg"
59
+ ],
60
+ :default => [
61
+ :media, :text, :archive, :images, :fonts
62
+ ]
63
+ }
64
+
65
+ private
66
+
67
+ class LocalFile
68
+ def initialize(root, path)
69
+ @root = root
70
+ @path = path
71
+ @etag = Digest::SHA1.hexdigest("#{File.size(full_path)}#{mtime_date}")
72
+
73
+ @range = nil
74
+ end
75
+
76
+ attr :root
77
+ attr :path
78
+ attr :etag
79
+ attr :range
80
+
81
+ # Fit in with Rack::Sendfile
82
+ def to_path
83
+ full_path
84
+ end
85
+
86
+ def full_path
87
+ File.join(@root, @path.components)
88
+ end
89
+
90
+ def mtime_date
91
+ File.mtime(full_path).httpdate
92
+ end
93
+
94
+ def size
95
+ File.size(full_path)
96
+ end
97
+
98
+ def each
99
+ File.open(full_path, "rb") do |file|
100
+ file.seek(@range.begin)
101
+ remaining = @range.end - @range.begin+1
102
+
103
+ while remaining > 0
104
+ break unless part = file.read([8192, remaining].min)
105
+
106
+ remaining -= part.length
107
+
108
+ yield part
109
+ end
110
+ end
111
+ end
112
+
113
+ def modified?(env)
114
+ if modified_since = env['HTTP_IF_MODIFIED_SINCE']
115
+ return false if File.mtime(full_path) <= Time.parse(modified_since)
116
+ end
117
+
118
+ if etags = env['HTTP_IF_NONE_MATCH']
119
+ etags = etags.split(/\s*,\s*/)
120
+ return false if etags.include?(etag) || etags.include?('*')
121
+ end
122
+
123
+ return true
124
+ end
125
+
126
+ def serve(env, response_headers)
127
+ ranges = Rack::Utils.byte_ranges(env, size)
128
+ response = [200, response_headers, self]
129
+
130
+ # LOG.info("Requesting ranges: #{ranges.inspect} (#{size})")
131
+
132
+ if ranges == nil or ranges.size != 1
133
+ # No ranges, or multiple ranges (which we don't support).
134
+ # TODO: Support multiple byte-ranges, for now just send entire file:
135
+ response[0] = 200
136
+ response[1]["Content-Length"] = size.to_s
137
+ @range = 0..size-1
138
+ else
139
+ # Partial content:
140
+ @range = ranges[0]
141
+ response[0] = 206
142
+ response[1]["Content-Range"] = "bytes #{@range.begin}-#{@range.end}/#{size}"
143
+ response[1]["Content-Length"] = (@range.end - @range.begin+1).to_s
144
+ size = @range.end - @range.begin + 1
145
+ end
146
+
147
+ # LOG.debug {"Serving file #{full_path.inspect}, range #{@range.inspect}"}
148
+
149
+ return response
150
+ end
151
+ end
152
+
153
+ def load_mime_types(types)
154
+ result = {}
155
+
156
+ extract_extensions = lambda do |mime_type|
157
+ # LOG.info "Extracting #{mime_type.inspect}"
158
+ mime_type.extensions.each{|ext| result["." + ext] = mime_type.content_type}
159
+ end
160
+
161
+ types.each do |type|
162
+ current_count = result.size
163
+ # LOG.info "Processing #{type.inspect}"
164
+
165
+ begin
166
+ case type
167
+ when Symbol
168
+ result = load_mime_types(MIME_TYPES[type]).merge(result)
169
+ when Array
170
+ result["." + type[0]] = type[1]
171
+ when String
172
+ mt = MIME::Types.of(type).select{|mt| !mt.obsolete?}.each do |mt|
173
+ extract_extensions.call(mt)
174
+ end
175
+ when Regexp
176
+ MIME::Types[type].select{|mt| !mt.obsolete?}.each do |mt|
177
+ extract_extensions.call(mt)
178
+ end
179
+ when MIME::Type
180
+ extract_extensions.call(type)
181
+ end
182
+ rescue
183
+ LOG.error "#{self.class.name}: Error while processing #{type.inspect}!"
184
+ raise $!
185
+ end
186
+
187
+ if result.size == current_count
188
+ LOG.warn "#{self.class.name}: Could not find any mime type for #{type.inspect}"
189
+ end
190
+ end
191
+
192
+ return result
193
+ end
194
+
195
+ public
196
+
197
+ def initialize(app, options = {})
198
+ @app = app
199
+ @root = options[:root] || Utopia::default_root
200
+
201
+ if options[:types]
202
+ @extensions = load_mime_types(options[:types])
203
+ else
204
+ @extensions = load_mime_types(MIME_TYPES[:default])
205
+ end
206
+
207
+ @cache_control = options[:cache_control] || "public, max-age=3600"
208
+ end
209
+
210
+ def fetch_file(path)
211
+ # We need file_path to be an absolute path for X-Sendfile to work correctly.
212
+ file_path = File.join(@root, path.components)
213
+
214
+ if File.exist?(file_path)
215
+ return LocalFile.new(@root, path)
216
+ else
217
+ return nil
218
+ end
219
+ end
220
+
221
+ attr :extensions
222
+
223
+ def call(env)
224
+ path_info = env['PATH_INFO']
225
+ extension = File.extname(path_info)
226
+
227
+ if @extensions.key? extension.downcase
228
+ path = Path[path_info].simplify
229
+
230
+ if locale = env[Localization::CURRENT_LOCALE_KEY]
231
+ path.last.insert(path.last.rindex('.') || -1, ".#{locale}")
232
+ end
233
+
234
+ if file = fetch_file(path)
235
+ response_headers = {
236
+ "Last-Modified" => file.mtime_date,
237
+ "Content-Type" => @extensions[extension],
238
+ "Cache-Control" => @cache_control,
239
+ "ETag" => file.etag,
240
+ "Accept-Ranges" => "bytes"
241
+ }
242
+
243
+ if file.modified?(env)
244
+ return file.serve(env, response_headers)
245
+ else
246
+ return [304, response_headers, []]
247
+ end
248
+ end
249
+ end
250
+
251
+ # else if no file was found:
252
+ return @app.call(env)
253
+ end
254
+ end
255
+ end