utopia 0.12.6 → 1.0.0

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