utopia 0.12.6 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +6 -2
- data/Gemfile +6 -0
- data/README.md +48 -14
- data/Rakefile +5 -0
- data/bin/utopia +132 -15
- data/lib/utopia.rb +13 -10
- data/lib/utopia/content.rb +140 -0
- data/lib/utopia/content/link.rb +124 -0
- data/lib/utopia/content/links.rb +228 -0
- data/lib/utopia/content/node.rb +387 -0
- data/lib/utopia/content/processor.rb +128 -0
- data/lib/utopia/content/tag.rb +102 -0
- data/lib/utopia/controller.rb +137 -0
- data/lib/utopia/controller/action.rb +112 -0
- data/lib/utopia/controller/base.rb +174 -0
- data/lib/utopia/{middleware/controller → controller}/variables.rb +36 -38
- data/lib/utopia/exception_handler.rb +79 -0
- data/lib/utopia/extensions/array.rb +2 -2
- data/lib/utopia/localization.rb +143 -0
- data/lib/utopia/mail_exceptions.rb +136 -0
- data/lib/utopia/middleware.rb +7 -22
- data/lib/utopia/path.rb +150 -60
- data/lib/utopia/redirector.rb +152 -0
- data/lib/utopia/{extensions/hash.rb → session.rb} +4 -6
- data/lib/utopia/session/encrypted_cookie.rb +46 -48
- data/lib/utopia/{middleware/directory_index.rb → session/lazy_hash.rb} +44 -27
- data/lib/utopia/static.rb +255 -0
- data/lib/utopia/tags/deferred.rb +12 -8
- data/lib/utopia/tags/environment.rb +18 -6
- data/lib/utopia/tags/node.rb +12 -8
- data/lib/utopia/tags/override.rb +12 -12
- data/lib/utopia/version.rb +1 -1
- data/setup/.bowerrc +3 -0
- data/{lib/utopia/setup → setup}/Gemfile +1 -1
- data/setup/Rakefile +4 -0
- data/{lib/utopia/setup → setup}/cache/head/readme.txt +0 -0
- data/{lib/utopia/setup → setup}/cache/meta/readme.txt +0 -0
- data/setup/config.ru +64 -0
- data/{lib/utopia/setup → setup}/lib/readme.txt +0 -0
- data/{lib/utopia/setup → setup}/pages/_heading.xnode +0 -0
- data/{lib/utopia/setup → setup}/pages/_page.xnode +1 -1
- data/{lib/utopia/setup → setup}/pages/_static/icon.png +0 -0
- data/setup/pages/_static/site.css +70 -0
- data/{lib/utopia/setup → setup}/pages/errors/exception.xnode +0 -0
- data/{lib/utopia/setup → setup}/pages/errors/file-not-found.xnode +0 -0
- data/{lib/utopia/setup → setup}/pages/links.yaml +0 -0
- data/setup/pages/welcome/index.xnode +17 -0
- data/{lib/utopia/setup → setup}/public/readme.txt +0 -0
- data/spec/utopia/content/link_spec.rb +108 -0
- data/spec/utopia/content/links/foo/index.xnode +0 -0
- data/spec/utopia/content/links/foo/links.yaml +2 -0
- data/spec/utopia/content/links/foo/test.de.xnode +0 -0
- data/spec/utopia/content/links/foo/test.en.xnode +0 -0
- data/spec/utopia/content/links/links.yaml +9 -0
- data/spec/utopia/content/links/welcome.xnode +0 -0
- data/spec/utopia/content/localized/five/index.en.xnode +0 -0
- data/spec/utopia/content/localized/four/index.en.xnode +0 -0
- data/spec/utopia/content/localized/four/index.zh.xnode +0 -0
- data/spec/utopia/content/localized/four/links.yaml +4 -0
- data/spec/utopia/content/localized/links.yaml +16 -0
- data/spec/utopia/content/localized/one.xnode +0 -0
- data/spec/utopia/content/localized/three/index.xnode +0 -0
- data/spec/utopia/content/localized/two.en.xnode +0 -0
- data/spec/utopia/content/localized/two.zh.xnode +0 -0
- data/spec/utopia/content/node/ordered/first.xnode +0 -0
- data/spec/utopia/content/node/ordered/index.xnode +0 -0
- data/spec/utopia/content/node/ordered/links.yaml +4 -0
- data/spec/utopia/content/node/ordered/second.xnode +0 -0
- data/spec/utopia/content/node/related/foo.en.xnode +0 -0
- data/spec/utopia/content/node/related/foo.ja.xnode +0 -0
- data/spec/utopia/content/node/related/links.yaml +4 -0
- data/spec/utopia/content/node_spec.rb +63 -0
- data/spec/utopia/{middleware/content_spec.rb → content/processor_spec.rb} +34 -23
- data/spec/utopia/content_spec.rb +87 -0
- data/spec/utopia/content_spec.ru +10 -0
- data/spec/utopia/{middleware/controller_spec.rb → controller_spec.rb} +61 -16
- data/spec/utopia/controller_spec.ru +4 -0
- data/spec/utopia/extensions_spec.rb +6 -17
- data/spec/utopia/localization_spec.rb +60 -0
- data/spec/utopia/localization_spec.ru +11 -0
- data/{lib/utopia/tags.rb → spec/utopia/middleware_spec.rb} +8 -14
- data/spec/utopia/{middleware/content_root → pages}/_heading.xnode +0 -0
- data/spec/utopia/pages/content/_show-value.xnode +1 -0
- data/spec/utopia/pages/content/test-partial.xnode +1 -0
- data/spec/utopia/pages/controller/controller.rb +28 -0
- data/spec/utopia/pages/controller/index.xnode +1 -0
- data/spec/utopia/pages/controller/nested/controller.rb +4 -0
- data/spec/utopia/{middleware/content_root → pages}/index.xnode +0 -0
- data/spec/utopia/pages/localized.de.txt +1 -0
- data/spec/utopia/pages/localized.en.txt +1 -0
- data/spec/utopia/pages/localized.jp.txt +1 -0
- data/spec/utopia/pages/node/index.xnode +1 -0
- data/spec/utopia/pages/test.txt +1 -0
- data/spec/utopia/path_spec.rb +109 -0
- data/spec/utopia/rack_spec.rb +2 -0
- data/spec/utopia/session_spec.rb +82 -0
- data/spec/utopia/session_spec.ru +20 -0
- data/spec/utopia/spec_helper.rb +16 -0
- data/{lib/utopia/extensions/string.rb → spec/utopia/static_spec.rb} +24 -15
- data/spec/utopia/static_spec.ru +4 -0
- data/utopia.gemspec +3 -3
- metadata +138 -54
- data/lib/utopia/extensions/regexp.rb +0 -33
- data/lib/utopia/link.rb +0 -288
- data/lib/utopia/middleware/all.rb +0 -33
- data/lib/utopia/middleware/content.rb +0 -157
- data/lib/utopia/middleware/content/node.rb +0 -386
- data/lib/utopia/middleware/content/processor.rb +0 -123
- data/lib/utopia/middleware/controller.rb +0 -130
- data/lib/utopia/middleware/controller/action.rb +0 -121
- data/lib/utopia/middleware/controller/base.rb +0 -184
- data/lib/utopia/middleware/exception_handler.rb +0 -80
- data/lib/utopia/middleware/localization.rb +0 -147
- data/lib/utopia/middleware/localization/name.rb +0 -69
- data/lib/utopia/middleware/mail_exceptions.rb +0 -138
- data/lib/utopia/middleware/redirector.rb +0 -146
- data/lib/utopia/middleware/requester.rb +0 -126
- data/lib/utopia/middleware/static.rb +0 -295
- data/lib/utopia/setup.rb +0 -60
- data/lib/utopia/setup/config.ru +0 -47
- data/lib/utopia/setup/pages/_static/background.png +0 -0
- data/lib/utopia/setup/pages/_static/site.css +0 -48
- data/lib/utopia/setup/pages/welcome/index.xnode +0 -7
- data/lib/utopia/tag.rb +0 -105
- 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,
|
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
|
-
|
22
|
-
|
23
|
-
|
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
|
-
|
29
|
-
RACK_SESSION_OPTIONS = "rack.session.options"
|
30
|
-
|
31
|
-
def initialize(app, options={})
|
31
|
+
def initialize(app, options = {})
|
32
32
|
@app = app
|
33
|
-
@
|
34
|
-
|
33
|
+
@cookie_name = options.delete(:cookie_name) || (RACK_SESSION + ".encrypted")
|
34
|
+
|
35
|
+
@secret = options.delete(:secret)
|
35
36
|
|
36
|
-
@
|
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
|
-
|
45
|
+
session_hash = prepare_session(env)
|
45
46
|
|
46
47
|
status, headers, body = @app.call(env)
|
47
48
|
|
48
|
-
if
|
49
|
-
|
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
|
-
|
56
|
-
|
57
|
-
def
|
58
|
-
|
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[@
|
62
|
-
|
69
|
+
data = request.cookies[@cookie_name]
|
70
|
+
|
63
71
|
if data
|
64
|
-
|
72
|
+
values = decrypt(data) rescue values
|
65
73
|
end
|
66
|
-
|
67
|
-
|
68
|
-
env[RACK_SESSION_OPTIONS] = @default_options.dup
|
69
|
-
|
70
|
-
return session
|
74
|
+
|
75
|
+
return values
|
71
76
|
end
|
72
77
|
|
73
|
-
def
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
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,
|
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
|
-
|
22
|
-
require 'utopia/path'
|
21
|
+
require_relative '../session'
|
23
22
|
|
24
23
|
module Utopia
|
25
|
-
module
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
@
|
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
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
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
|
-
|
48
|
-
|
49
|
-
|
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
|