utopia 2.32.0 → 3.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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/bake/utopia/environment.rb +6 -6
- data/bake/utopia/server.rb +4 -1
- data/bake/utopia/site.rb +14 -14
- data/bake/utopia/static.rb +26 -13
- data/bake/utopia.rb +1 -1
- data/context/getting-started.md +7 -5
- data/context/index.yaml +1 -2
- data/context/middleware.md +42 -13
- data/lib/utopia/application.rb +83 -0
- data/lib/utopia/content/builder.rb +25 -0
- data/lib/utopia/content/document.rb +64 -6
- data/lib/utopia/content/link.rb +50 -5
- data/lib/utopia/content/links.rb +44 -7
- data/lib/utopia/content/markup.rb +74 -0
- data/lib/utopia/content/middleware.rb +48 -20
- data/lib/utopia/content/namespace.rb +10 -1
- data/lib/utopia/content/node.rb +66 -4
- data/lib/utopia/content/response.rb +12 -3
- data/lib/utopia/content.rb +3 -0
- data/lib/utopia/controller/actions.md +4 -4
- data/lib/utopia/controller/actions.rb +51 -3
- data/lib/utopia/controller/base.rb +72 -29
- data/lib/utopia/controller/middleware.rb +20 -15
- data/lib/utopia/controller/respond.rb +36 -32
- data/lib/utopia/controller/responder.rb +106 -45
- data/lib/utopia/controller/result.rb +11 -0
- data/lib/utopia/controller/rewrite.rb +39 -0
- data/lib/utopia/controller/variables.rb +15 -3
- data/lib/utopia/controller.rb +2 -0
- data/lib/utopia/exceptions/handler.rb +20 -11
- data/lib/utopia/exceptions/mailer.rb +56 -51
- data/lib/utopia/extensions/array_split.rb +6 -0
- data/lib/utopia/extensions/date_comparisons.rb +6 -0
- data/lib/utopia/http.rb +11 -48
- data/lib/utopia/import_map.rb +19 -11
- data/lib/utopia/localization/locales.rb +60 -0
- data/lib/utopia/localization/middleware.rb +107 -73
- data/lib/utopia/localization/preferences.rb +76 -0
- data/lib/utopia/localization/resolver.rb +47 -0
- data/lib/utopia/localization.rb +6 -0
- data/lib/utopia/middleware.rb +3 -4
- data/lib/utopia/path/matcher.rb +15 -0
- data/lib/utopia/path.rb +146 -10
- data/lib/utopia/redirection/client_redirect.rb +87 -0
- data/lib/utopia/redirection/directory_index.rb +41 -0
- data/lib/utopia/redirection/errors.rb +78 -0
- data/lib/utopia/redirection/moved.rb +52 -0
- data/lib/utopia/redirection/request_failure.rb +26 -0
- data/lib/utopia/redirection/rewrite.rb +42 -0
- data/lib/utopia/redirection.rb +7 -165
- data/lib/utopia/request.rb +202 -0
- data/lib/utopia/response.rb +73 -0
- data/lib/utopia/session/lazy_hash.rb +27 -1
- data/lib/utopia/session/middleware.rb +103 -30
- data/lib/utopia/session/serialization.rb +8 -0
- data/lib/utopia/session.rb +5 -0
- data/lib/utopia/setup.rb +23 -3
- data/lib/utopia/shell.rb +28 -7
- data/lib/utopia/static/local_file.rb +84 -61
- data/lib/utopia/static/middleware.rb +65 -33
- data/lib/utopia/static/mime_types.rb +38 -29
- data/lib/utopia/static.rb +2 -0
- data/lib/utopia/version.rb +3 -2
- data/lib/utopia.rb +1 -1
- data/license.md +1 -1
- data/readme.md +33 -4
- data/releases.md +15 -0
- data/setup/site/bake.rb +1 -1
- data/setup/site/config/application.rb +51 -0
- data/setup/site/config/serve.rb +8 -0
- data/setup/site/falcon.rb +17 -4
- data/setup/site/fixtures/website.rb +27 -11
- data/setup/site/gems.rb +1 -3
- data/setup/site/lib/readme.txt +1 -1
- data/setup/site/pages/welcome/index.xnode +3 -3
- data/setup/site/readme.md +0 -3
- data/setup/site/test/website.rb +2 -2
- data.tar.gz.sig +0 -0
- metadata +60 -34
- metadata.gz.sig +0 -0
- data/lib/utopia/localization/wrapper.rb +0 -52
- data/setup/site/Guardfile +0 -12
- data/setup/site/config.ru +0 -49
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
|
-
# Copyright,
|
|
5
|
-
# Copyright, 2019, by Huba Nagy.
|
|
4
|
+
# Copyright, 2025-2026, by Samuel Williams.
|
|
6
5
|
|
|
7
6
|
require "openssl"
|
|
8
7
|
require "digest/sha2"
|
|
9
8
|
require "console"
|
|
10
9
|
require "json"
|
|
11
10
|
|
|
11
|
+
require "protocol/http/cookie"
|
|
12
|
+
|
|
12
13
|
require_relative "lazy_hash"
|
|
13
14
|
require_relative "serialization"
|
|
15
|
+
require_relative "../middleware"
|
|
16
|
+
require_relative "../request"
|
|
17
|
+
require_relative "../response"
|
|
14
18
|
|
|
15
19
|
module Utopia
|
|
16
20
|
module Session
|
|
17
21
|
# A middleware which provides a secure client-side session storage using a private symmetric encrpytion key.
|
|
18
|
-
class Middleware
|
|
22
|
+
class Middleware < Protocol::HTTP::Middleware
|
|
23
|
+
# Raised when payload processing fails.
|
|
19
24
|
class PayloadError < StandardError
|
|
20
25
|
end
|
|
21
26
|
|
|
@@ -23,7 +28,7 @@ module Utopia
|
|
|
23
28
|
|
|
24
29
|
SECRET_KEY = "UTOPIA_SESSION_SECRET".freeze
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
SESSION_KEY = "utopia.session".freeze
|
|
27
32
|
CIPHER_ALGORITHM = "aes-256-cbc"
|
|
28
33
|
|
|
29
34
|
# The session will expire if no requests were made within 24 hours:
|
|
@@ -33,12 +38,19 @@ module Utopia
|
|
|
33
38
|
DEFAULT_UPDATE_TIMEOUT = 3600
|
|
34
39
|
|
|
35
40
|
# @param session_name [String] The name of the session cookie.
|
|
36
|
-
# @param secret [
|
|
37
|
-
# @param
|
|
38
|
-
# @param
|
|
39
|
-
# @param
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
# @param secret [String] The secret text used to generate a symmetric encryption key for the cookie data.
|
|
42
|
+
# @param expires_after [Numeric | Nil] The maximum session inactivity in seconds.
|
|
43
|
+
# @param update_timeout [Numeric | Nil] The maximum interval between session cookie updates.
|
|
44
|
+
# @param domain [String | Nil] The domain for which the cookie is valid.
|
|
45
|
+
# @param path [String | Nil] The path for which the cookie is valid.
|
|
46
|
+
# @param max_age [Integer | Nil] The browser cookie lifetime in seconds.
|
|
47
|
+
# @param secure [Boolean] Whether the cookie requires a secure connection.
|
|
48
|
+
# @param http_only [Boolean] Whether client-side scripts may access the cookie.
|
|
49
|
+
# @param same_site [Symbol | String | Boolean | Nil] Controls whether the cookie is sent with cross-site requests.
|
|
50
|
+
# @param partitioned [Boolean] Whether the cookie uses partitioned storage.
|
|
51
|
+
# @param maximum_size [Integer | Nil] The maximum encoded session payload size.
|
|
52
|
+
def initialize(app, session_name: SESSION_KEY, secret: nil, expires_after: DEFAULT_EXPIRES_AFTER, update_timeout: DEFAULT_UPDATE_TIMEOUT, domain: nil, path: "/", max_age: nil, secure: false, http_only: true, same_site: :lax, partitioned: false, maximum_size: MAXIMUM_SIZE)
|
|
53
|
+
super(app)
|
|
42
54
|
|
|
43
55
|
@session_name = session_name
|
|
44
56
|
@cookie_name = @session_name + ".encrypted"
|
|
@@ -54,18 +66,20 @@ module Utopia
|
|
|
54
66
|
@update_timeout = update_timeout
|
|
55
67
|
|
|
56
68
|
@cookie_defaults = {
|
|
57
|
-
domain:
|
|
58
|
-
path:
|
|
69
|
+
domain: domain,
|
|
70
|
+
path: path,
|
|
71
|
+
max_age: max_age,
|
|
59
72
|
|
|
60
73
|
# The SameSite attribute controls when the cookie is sent to the server, from 3rd parties (None), from requests with external referrers (Lax) or from within the site itself (Strict).
|
|
61
|
-
same_site: same_site,
|
|
74
|
+
same_site: normalize_same_site(same_site),
|
|
62
75
|
|
|
63
76
|
# The Secure attribute is meant to keep cookie communication limited to encrypted transmission, directing browsers to use cookies only via secure/encrypted connections. However, if a web server sets a cookie with a secure attribute from a non-secure connection, the cookie can still be intercepted when it is sent to the user by man-in-the-middle attacks. Therefore, for maximum security, cookies with the Secure attribute should only be set over a secure connection.
|
|
64
77
|
secure: secure,
|
|
65
78
|
|
|
66
79
|
# The HttpOnly attribute directs browsers not to expose cookies through channels other than HTTP (and HTTPS) requests. This means that the cookie cannot be accessed via client-side scripting languages (notably JavaScript), and therefore cannot be stolen easily via cross-site scripting (a pervasive attack technique).
|
|
67
|
-
http_only:
|
|
68
|
-
|
|
80
|
+
http_only: http_only,
|
|
81
|
+
partitioned: partitioned,
|
|
82
|
+
}
|
|
69
83
|
|
|
70
84
|
@serialization = Serialization.new
|
|
71
85
|
@maximum_size = maximum_size
|
|
@@ -79,6 +93,8 @@ module Utopia
|
|
|
79
93
|
|
|
80
94
|
attr :cookie_defaults
|
|
81
95
|
|
|
96
|
+
# Freeze this object and its internal state.
|
|
97
|
+
# @returns [self] This object.
|
|
82
98
|
def freeze
|
|
83
99
|
return self if frozen?
|
|
84
100
|
|
|
@@ -91,25 +107,44 @@ module Utopia
|
|
|
91
107
|
super
|
|
92
108
|
end
|
|
93
109
|
|
|
94
|
-
|
|
95
|
-
|
|
110
|
+
# Attach a lazily loaded session to the request, then persist it.
|
|
111
|
+
# @parameter request [Utopia::Request] The request.
|
|
112
|
+
# @returns [Protocol::HTTP::Response] The wrapped application response.
|
|
113
|
+
def call(request)
|
|
114
|
+
request.session = prepare_session(request)
|
|
96
115
|
|
|
97
|
-
|
|
116
|
+
response = Response.wrap(@delegate.call(request))
|
|
98
117
|
|
|
99
|
-
update_session(
|
|
118
|
+
update_session(request.session, response.headers)
|
|
100
119
|
|
|
101
|
-
return
|
|
120
|
+
return response
|
|
102
121
|
end
|
|
103
122
|
|
|
104
123
|
protected
|
|
105
124
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
125
|
+
# Normalize a SameSite option to its cookie directive value:
|
|
126
|
+
def normalize_same_site(same_site)
|
|
127
|
+
case same_site
|
|
128
|
+
when false, nil
|
|
129
|
+
return nil
|
|
130
|
+
when :none, "None", :None
|
|
131
|
+
return "None"
|
|
132
|
+
when :lax, "Lax", :Lax
|
|
133
|
+
return "Lax"
|
|
134
|
+
when true, :strict, "Strict", :Strict
|
|
135
|
+
return "Strict"
|
|
136
|
+
else
|
|
137
|
+
raise ArgumentError, "Invalid same_site value: #{same_site.inspect}!"
|
|
109
138
|
end
|
|
110
139
|
end
|
|
111
140
|
|
|
112
|
-
def
|
|
141
|
+
def prepare_session(request)
|
|
142
|
+
LazyHash.new do
|
|
143
|
+
self.load_session_values(request)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def update_session(session_hash, headers)
|
|
113
148
|
if session_hash.needs_update?(@update_timeout)
|
|
114
149
|
values = session_hash.values
|
|
115
150
|
|
|
@@ -132,9 +167,7 @@ module Utopia
|
|
|
132
167
|
|
|
133
168
|
# Load session from user supplied cookie. If the data is invalid or otherwise fails validation, `build_iniital_session` is invoked.
|
|
134
169
|
# @return hash of values.
|
|
135
|
-
def load_session_values(
|
|
136
|
-
request = Rack::Request.new(env)
|
|
137
|
-
|
|
170
|
+
def load_session_values(request)
|
|
138
171
|
# Decrypt the data from the user if possible:
|
|
139
172
|
if data = request.cookies[@cookie_name]
|
|
140
173
|
begin
|
|
@@ -178,7 +211,45 @@ module Utopia
|
|
|
178
211
|
expires: expires(updated_at)
|
|
179
212
|
}.merge(@cookie_defaults)
|
|
180
213
|
|
|
181
|
-
|
|
214
|
+
headers.add("set-cookie", cookie_header(@cookie_name, cookie))
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def cookie_header(name, cookie)
|
|
218
|
+
directives = {}
|
|
219
|
+
|
|
220
|
+
if domain = cookie[:domain]
|
|
221
|
+
directives["Domain"] = domain
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
if path = cookie[:path]
|
|
225
|
+
directives["Path"] = path
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
if max_age = cookie[:max_age]
|
|
229
|
+
directives["Max-Age"] = max_age
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
if expires = cookie[:expires]
|
|
233
|
+
directives["Expires"] = expires.httpdate
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
if cookie[:secure]
|
|
237
|
+
directives["Secure"] = true
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
if cookie[:http_only]
|
|
241
|
+
directives["HttpOnly"] = true
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
if same_site = cookie[:same_site]
|
|
245
|
+
directives["SameSite"] = same_site
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
if cookie[:partitioned]
|
|
249
|
+
directives["Partitioned"] = true
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
return Protocol::HTTP::Cookie.new(name, cookie.fetch(:value), directives).to_s
|
|
182
253
|
end
|
|
183
254
|
|
|
184
255
|
def encrypt(hash)
|
|
@@ -192,7 +263,7 @@ module Utopia
|
|
|
192
263
|
e = c.update(@serialization.dump(hash))
|
|
193
264
|
e << c.final
|
|
194
265
|
|
|
195
|
-
return [iv
|
|
266
|
+
return [iv + e].pack("m0")
|
|
196
267
|
end
|
|
197
268
|
|
|
198
269
|
def decrypt(data)
|
|
@@ -200,7 +271,9 @@ module Utopia
|
|
|
200
271
|
raise PayloadError, "Session payload size #{data.bytesize}bytes exceeds maximum allowed size #{@maximum_size}bytes!"
|
|
201
272
|
end
|
|
202
273
|
|
|
203
|
-
|
|
274
|
+
payload = data.unpack1("m0")
|
|
275
|
+
iv = payload.byteslice(0, 16)
|
|
276
|
+
e = payload.byteslice(16..)
|
|
204
277
|
|
|
205
278
|
c = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
|
|
206
279
|
c.decrypt
|
|
@@ -10,7 +10,9 @@ require "date"
|
|
|
10
10
|
|
|
11
11
|
module Utopia
|
|
12
12
|
module Session
|
|
13
|
+
# Encodes and decodes session values using MessagePack.
|
|
13
14
|
class Serialization
|
|
15
|
+
# Initialize a MessagePack factory with session-specific scalar types.
|
|
14
16
|
def initialize
|
|
15
17
|
@factory = MessagePack::Factory.new
|
|
16
18
|
|
|
@@ -23,10 +25,16 @@ module Utopia
|
|
|
23
25
|
|
|
24
26
|
attr :factory
|
|
25
27
|
|
|
28
|
+
# Decode a MessagePack session value.
|
|
29
|
+
# @parameter data [String] The serialized data.
|
|
30
|
+
# @returns [Object] The decoded value.
|
|
26
31
|
def load(data)
|
|
27
32
|
@factory.unpack(data)
|
|
28
33
|
end
|
|
29
34
|
|
|
35
|
+
# Encode a session value using MessagePack.
|
|
36
|
+
# @parameter object [Object] The value to encode.
|
|
37
|
+
# @returns [String] The encoded value.
|
|
30
38
|
def dump(object)
|
|
31
39
|
@factory.pack(object)
|
|
32
40
|
end
|
data/lib/utopia/session.rb
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
require_relative "session/middleware"
|
|
8
8
|
|
|
9
9
|
module Utopia
|
|
10
|
+
# Provides cookie-backed session middleware.
|
|
10
11
|
module Session
|
|
12
|
+
# Build session middleware around an application.
|
|
13
|
+
# @parameter arguments [Array] Positional arguments forwarded to {Middleware#initialize}.
|
|
14
|
+
# @parameter options [Hash] Keyword arguments forwarded to {Middleware#initialize}.
|
|
15
|
+
# @returns [Middleware] The session middleware.
|
|
11
16
|
def self.new(...)
|
|
12
17
|
Middleware.new(...)
|
|
13
18
|
end
|
data/lib/utopia/setup.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
|
-
# Copyright, 2010-
|
|
4
|
+
# Copyright, 2010-2026, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require "yaml"
|
|
7
7
|
require "securerandom"
|
|
@@ -13,36 +13,54 @@ require "console"
|
|
|
13
13
|
module Utopia
|
|
14
14
|
# Used for setting up a Utopia web application, typically via `config/environment.rb`
|
|
15
15
|
class Setup
|
|
16
|
+
# Initialize application setup for the given root.
|
|
17
|
+
# @parameter root [String] The root directory.
|
|
18
|
+
# @parameter options [Hash] Reserved setup options.
|
|
16
19
|
def initialize(root, **options)
|
|
17
20
|
@root = root
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
attr :root
|
|
21
24
|
|
|
25
|
+
# Resolve the application's configuration directory.
|
|
26
|
+
# @returns [String] The absolute configuration path.
|
|
22
27
|
def config_root
|
|
23
28
|
File.expand_path("config", @root)
|
|
24
29
|
end
|
|
25
30
|
|
|
31
|
+
# Return the application root.
|
|
32
|
+
# @returns [String] The application root path.
|
|
26
33
|
def site_root
|
|
27
34
|
@root
|
|
28
35
|
end
|
|
29
36
|
|
|
37
|
+
# Check whether the application is running in production.
|
|
38
|
+
# @returns [Boolean] Whether the active Utopia variant is `production`.
|
|
30
39
|
def production?
|
|
31
40
|
Variant.for(:utopia) == :production
|
|
32
41
|
end
|
|
33
42
|
|
|
43
|
+
# Check whether the application is running in staging.
|
|
44
|
+
# @returns [Boolean] Whether the active Utopia variant is `staging`.
|
|
34
45
|
def staging?
|
|
35
46
|
Variant.for(:utopia) == :staging
|
|
36
47
|
end
|
|
37
48
|
|
|
49
|
+
# Check whether the application is running in development.
|
|
50
|
+
# @returns [Boolean] Whether the active Utopia variant is `development`.
|
|
38
51
|
def development?
|
|
39
52
|
Variant.for(:utopia) == :development
|
|
40
53
|
end
|
|
41
54
|
|
|
55
|
+
# Check whether the application is running in testing.
|
|
56
|
+
# @returns [Boolean] Whether the active Utopia variant is `testing`.
|
|
42
57
|
def testing?
|
|
43
58
|
Variant.for(:utopia) == :testing
|
|
44
59
|
end
|
|
45
60
|
|
|
61
|
+
# Fetch the configured secret for the given key.
|
|
62
|
+
# @parameter key [String | Symbol] The lookup key.
|
|
63
|
+
# @returns [String] The configured secret, or a newly generated transient secret when none is configured.
|
|
46
64
|
def secret_for(key)
|
|
47
65
|
secret = ENV["UTOPIA_#{key.upcase}_SECRET"]
|
|
48
66
|
|
|
@@ -57,6 +75,8 @@ module Utopia
|
|
|
57
75
|
return secret
|
|
58
76
|
end
|
|
59
77
|
|
|
78
|
+
# Apply.
|
|
79
|
+
# @returns [self] This object.
|
|
60
80
|
def apply!
|
|
61
81
|
add_load_path("lib")
|
|
62
82
|
|
|
@@ -93,7 +113,7 @@ module Utopia
|
|
|
93
113
|
path = environment_path(variant)
|
|
94
114
|
|
|
95
115
|
if File.exist?(path)
|
|
96
|
-
Console.debug(self)
|
|
116
|
+
Console.debug(self){"Loading environment at path: #{path.inspect}"}
|
|
97
117
|
|
|
98
118
|
# Load the YAML environment file:
|
|
99
119
|
if environment = YAML.load_file(path)
|
|
@@ -105,7 +125,7 @@ module Utopia
|
|
|
105
125
|
|
|
106
126
|
return true
|
|
107
127
|
else
|
|
108
|
-
Console.debug(self)
|
|
128
|
+
Console.debug(self){"Ignoring environment at path: #{path.inspect} (file not found)"}
|
|
109
129
|
|
|
110
130
|
return false
|
|
111
131
|
end
|
data/lib/utopia/shell.rb
CHANGED
|
@@ -3,30 +3,51 @@
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
4
|
# Copyright, 2020-2025, by Samuel Williams.
|
|
5
5
|
|
|
6
|
-
require "
|
|
7
|
-
|
|
6
|
+
require "protocol/http/request"
|
|
7
|
+
require_relative "application"
|
|
8
8
|
require "irb"
|
|
9
9
|
|
|
10
10
|
module Utopia
|
|
11
11
|
# This is designed to be used with the corresponding bake task.
|
|
12
12
|
class Shell
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
# Initialize a shell for a Bake context.
|
|
14
|
+
# @parameter context [Bake::Context] The context that provides the application root.
|
|
15
15
|
def initialize(context)
|
|
16
16
|
@context = context
|
|
17
17
|
@app = nil
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Load the configured application on first access.
|
|
21
|
+
# @returns [Application] The application middleware.
|
|
20
22
|
def app
|
|
21
|
-
@app ||=
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
@app ||= Application.load(File.expand_path(Application::PATH, @context.root))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Perform a GET request.
|
|
27
|
+
# @parameter path [Utopia::Path | String] The path.
|
|
28
|
+
# @parameter headers [Hash] The headers.
|
|
29
|
+
# @returns [Protocol::HTTP::Response] The application response.
|
|
30
|
+
def get(path, headers = nil)
|
|
31
|
+
app.call(Protocol::HTTP::Request["GET", path, headers])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Perform a POST request.
|
|
35
|
+
# @parameter path [Utopia::Path | String] The path.
|
|
36
|
+
# @parameter headers [Hash] The headers.
|
|
37
|
+
# @parameter body [Protocol::HTTP::Body::Readable | Nil] The request body.
|
|
38
|
+
# @returns [Protocol::HTTP::Response] The application response.
|
|
39
|
+
def post(path, headers = nil, body = nil)
|
|
40
|
+
app.call(Protocol::HTTP::Request["POST", path, headers, body])
|
|
24
41
|
end
|
|
25
42
|
|
|
43
|
+
# Convert this object to a string.
|
|
44
|
+
# @returns [String] The resulting string.
|
|
26
45
|
def to_s
|
|
27
46
|
self.class.name
|
|
28
47
|
end
|
|
29
48
|
|
|
49
|
+
# Expose this shell's binding to IRB.
|
|
50
|
+
# @returns [Binding] The shell binding.
|
|
30
51
|
def binding
|
|
31
52
|
super
|
|
32
53
|
end
|
|
@@ -1,107 +1,130 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
|
-
# Copyright, 2017-
|
|
4
|
+
# Copyright, 2017-2026, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require "time"
|
|
7
7
|
require "digest/sha1"
|
|
8
8
|
|
|
9
|
+
require "protocol/http/body/file"
|
|
10
|
+
require "protocol/http/body/head"
|
|
11
|
+
require "protocol/http/header/range"
|
|
12
|
+
|
|
13
|
+
require_relative "../response"
|
|
14
|
+
|
|
9
15
|
module Utopia
|
|
10
16
|
# A middleware which serves static files from the specified root directory.
|
|
11
17
|
module Static
|
|
12
|
-
# Represents a local
|
|
18
|
+
# Represents a local static resource and constructs responses for it.
|
|
13
19
|
class LocalFile
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
# Initialize metadata for a local file.
|
|
21
|
+
# @parameter path [String] The resolved filesystem path.
|
|
22
|
+
def initialize(path)
|
|
16
23
|
@path = path
|
|
17
|
-
@
|
|
24
|
+
@stat = File.stat(@path)
|
|
25
|
+
@mtime_date = @stat.mtime.httpdate
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
fingerprint = Digest::SHA1.hexdigest("#{@stat.size}:#{@stat.mtime.to_i}:#{@stat.mtime.nsec}")
|
|
28
|
+
@etag = %Q{W/"#{fingerprint}"}
|
|
20
29
|
end
|
|
21
30
|
|
|
22
|
-
attr :root
|
|
23
31
|
attr :path
|
|
24
32
|
attr :etag
|
|
25
|
-
attr :range
|
|
26
|
-
|
|
27
|
-
# Fit in with Rack::Sendfile
|
|
28
|
-
def to_path
|
|
29
|
-
full_path
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def full_path
|
|
33
|
-
File.join(@root, @path.components)
|
|
34
|
-
end
|
|
35
33
|
|
|
34
|
+
# Format the file's modification time for an HTTP header.
|
|
35
|
+
# @returns [String] The HTTP-date modification time.
|
|
36
36
|
def mtime_date
|
|
37
|
-
|
|
37
|
+
@mtime_date
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Measure the file's content length.
|
|
41
|
+
# @returns [Integer] The file size in bytes.
|
|
40
42
|
def bytesize
|
|
41
|
-
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# This reflects whether calling each would yield anything.
|
|
45
|
-
def empty?
|
|
46
|
-
bytesize == 0
|
|
43
|
+
@stat.size
|
|
47
44
|
end
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
while remaining > 0
|
|
57
|
-
break unless part = file.read([8192, remaining].min)
|
|
58
|
-
|
|
59
|
-
remaining -= part.length
|
|
60
|
-
|
|
61
|
-
yield part
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def modified?(env)
|
|
67
|
-
if modified_since = env["HTTP_IF_MODIFIED_SINCE"]
|
|
68
|
-
return false if File.mtime(full_path) <= Time.parse(modified_since)
|
|
46
|
+
# Check whether the file has changed since the request validators.
|
|
47
|
+
# @parameter request [Utopia::Request] The request.
|
|
48
|
+
# @returns [Boolean] Whether the file is newer than the request validators.
|
|
49
|
+
def modified?(request)
|
|
50
|
+
if etags = request.headers["if-none-match"]
|
|
51
|
+
return !etags.weak_match?(@etag)
|
|
69
52
|
end
|
|
70
53
|
|
|
71
|
-
if
|
|
72
|
-
|
|
73
|
-
return false if etags.include?(etag) || etags.include?("*")
|
|
54
|
+
if modified_since = request.headers["if-modified-since"]
|
|
55
|
+
return @stat.mtime.to_i > modified_since.to_time.to_i
|
|
74
56
|
end
|
|
75
57
|
|
|
76
58
|
return true
|
|
77
59
|
end
|
|
78
60
|
|
|
79
|
-
CONTENT_LENGTH =
|
|
80
|
-
CONTENT_RANGE = "
|
|
61
|
+
CONTENT_LENGTH = "content-length".freeze
|
|
62
|
+
CONTENT_RANGE = "content-range".freeze
|
|
81
63
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
64
|
+
# Serve.
|
|
65
|
+
# @parameter request [Utopia::Request] The request.
|
|
66
|
+
# @parameter response_headers [Hash] The response headers.
|
|
67
|
+
# @returns [Protocol::HTTP::Response] The response.
|
|
68
|
+
def serve(request, response_headers)
|
|
69
|
+
ranges = byte_ranges(request)
|
|
70
|
+
size = bytesize
|
|
85
71
|
|
|
86
72
|
# puts "Requesting ranges: #{ranges.inspect} (#{size})"
|
|
87
73
|
|
|
88
74
|
if ranges == nil or ranges.size != 1
|
|
89
75
|
# No ranges, or multiple ranges (which we don't support).
|
|
90
76
|
# TODO: Support multiple byte-ranges, for now just send entire file:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
77
|
+
status = 200
|
|
78
|
+
response_headers[CONTENT_LENGTH] = size.to_s
|
|
79
|
+
range = nil
|
|
94
80
|
else
|
|
95
81
|
# Partial content:
|
|
96
|
-
|
|
97
|
-
partial_size =
|
|
82
|
+
range = ranges[0]
|
|
83
|
+
partial_size = range.size
|
|
98
84
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
85
|
+
status = 206
|
|
86
|
+
response_headers[CONTENT_LENGTH] = partial_size.to_s
|
|
87
|
+
response_headers[CONTENT_RANGE] = "bytes #{range.min}-#{range.max}/#{size}"
|
|
102
88
|
end
|
|
103
89
|
|
|
104
|
-
|
|
90
|
+
if request.head?
|
|
91
|
+
body = Protocol::HTTP::Body::Head.new(size)
|
|
92
|
+
else
|
|
93
|
+
body = Protocol::HTTP::Body::File.open(@path, range, size: size)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
return Response[status, response_headers, body]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Resolve satisfiable byte ranges from the parsed range header.
|
|
100
|
+
# @parameter request [Utopia::Request] The request.
|
|
101
|
+
# @returns [Array | Nil] The resulting values, or `nil` if the range is not applicable.
|
|
102
|
+
def byte_ranges(request)
|
|
103
|
+
return nil unless request.method == Protocol::HTTP::Methods::GET
|
|
104
|
+
|
|
105
|
+
range = request.headers["range"]
|
|
106
|
+
return nil unless range&.bytes?
|
|
107
|
+
|
|
108
|
+
if validator = request.headers["if-range"]
|
|
109
|
+
return nil unless if_range?(validator)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
return range.resolve(bytesize)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Check whether an If-Range validator strongly matches this file.
|
|
116
|
+
# @parameter validator [String] The If-Range entity tag or HTTP date.
|
|
117
|
+
# @returns [Boolean] Whether the range can be served safely.
|
|
118
|
+
private def if_range?(validator)
|
|
119
|
+
validator = validator.to_s
|
|
120
|
+
|
|
121
|
+
# Date validators cannot be used because this file's modification date is not known to be strong:
|
|
122
|
+
return false unless validator.start_with?('"')
|
|
123
|
+
|
|
124
|
+
# Weak entity tags cannot authorize a partial response:
|
|
125
|
+
return false if @etag.start_with?("W/")
|
|
126
|
+
|
|
127
|
+
return @etag == validator
|
|
105
128
|
end
|
|
106
129
|
end
|
|
107
130
|
end
|