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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/bake/utopia/environment.rb +6 -6
  4. data/bake/utopia/server.rb +4 -1
  5. data/bake/utopia/site.rb +14 -14
  6. data/bake/utopia/static.rb +26 -13
  7. data/bake/utopia.rb +1 -1
  8. data/context/getting-started.md +7 -5
  9. data/context/index.yaml +1 -2
  10. data/context/middleware.md +42 -13
  11. data/lib/utopia/application.rb +83 -0
  12. data/lib/utopia/content/builder.rb +25 -0
  13. data/lib/utopia/content/document.rb +64 -6
  14. data/lib/utopia/content/link.rb +50 -5
  15. data/lib/utopia/content/links.rb +44 -7
  16. data/lib/utopia/content/markup.rb +74 -0
  17. data/lib/utopia/content/middleware.rb +48 -20
  18. data/lib/utopia/content/namespace.rb +10 -1
  19. data/lib/utopia/content/node.rb +66 -4
  20. data/lib/utopia/content/response.rb +12 -3
  21. data/lib/utopia/content.rb +3 -0
  22. data/lib/utopia/controller/actions.md +4 -4
  23. data/lib/utopia/controller/actions.rb +51 -3
  24. data/lib/utopia/controller/base.rb +72 -29
  25. data/lib/utopia/controller/middleware.rb +20 -15
  26. data/lib/utopia/controller/respond.rb +36 -32
  27. data/lib/utopia/controller/responder.rb +106 -45
  28. data/lib/utopia/controller/result.rb +11 -0
  29. data/lib/utopia/controller/rewrite.rb +39 -0
  30. data/lib/utopia/controller/variables.rb +15 -3
  31. data/lib/utopia/controller.rb +2 -0
  32. data/lib/utopia/exceptions/handler.rb +20 -11
  33. data/lib/utopia/exceptions/mailer.rb +56 -51
  34. data/lib/utopia/extensions/array_split.rb +6 -0
  35. data/lib/utopia/extensions/date_comparisons.rb +6 -0
  36. data/lib/utopia/http.rb +11 -48
  37. data/lib/utopia/import_map.rb +19 -11
  38. data/lib/utopia/localization/locales.rb +60 -0
  39. data/lib/utopia/localization/middleware.rb +107 -73
  40. data/lib/utopia/localization/preferences.rb +76 -0
  41. data/lib/utopia/localization/resolver.rb +47 -0
  42. data/lib/utopia/localization.rb +6 -0
  43. data/lib/utopia/middleware.rb +3 -4
  44. data/lib/utopia/path/matcher.rb +15 -0
  45. data/lib/utopia/path.rb +146 -10
  46. data/lib/utopia/redirection/client_redirect.rb +87 -0
  47. data/lib/utopia/redirection/directory_index.rb +41 -0
  48. data/lib/utopia/redirection/errors.rb +78 -0
  49. data/lib/utopia/redirection/moved.rb +52 -0
  50. data/lib/utopia/redirection/request_failure.rb +26 -0
  51. data/lib/utopia/redirection/rewrite.rb +42 -0
  52. data/lib/utopia/redirection.rb +7 -165
  53. data/lib/utopia/request.rb +202 -0
  54. data/lib/utopia/response.rb +73 -0
  55. data/lib/utopia/session/lazy_hash.rb +27 -1
  56. data/lib/utopia/session/middleware.rb +103 -30
  57. data/lib/utopia/session/serialization.rb +8 -0
  58. data/lib/utopia/session.rb +5 -0
  59. data/lib/utopia/setup.rb +23 -3
  60. data/lib/utopia/shell.rb +28 -7
  61. data/lib/utopia/static/local_file.rb +84 -61
  62. data/lib/utopia/static/middleware.rb +65 -33
  63. data/lib/utopia/static/mime_types.rb +38 -29
  64. data/lib/utopia/static.rb +2 -0
  65. data/lib/utopia/version.rb +3 -2
  66. data/lib/utopia.rb +1 -1
  67. data/license.md +1 -1
  68. data/readme.md +33 -4
  69. data/releases.md +15 -0
  70. data/setup/site/bake.rb +1 -1
  71. data/setup/site/config/application.rb +51 -0
  72. data/setup/site/config/serve.rb +8 -0
  73. data/setup/site/falcon.rb +17 -4
  74. data/setup/site/fixtures/website.rb +27 -11
  75. data/setup/site/gems.rb +1 -3
  76. data/setup/site/lib/readme.txt +1 -1
  77. data/setup/site/pages/welcome/index.xnode +3 -3
  78. data/setup/site/readme.md +0 -3
  79. data/setup/site/test/website.rb +2 -2
  80. data.tar.gz.sig +0 -0
  81. metadata +60 -34
  82. metadata.gz.sig +0 -0
  83. data/lib/utopia/localization/wrapper.rb +0 -52
  84. data/setup/site/Guardfile +0 -12
  85. 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, 2014-2025, by Samuel Williams.
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
- RACK_SESSION = "rack.session".freeze
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 [Array] The secret text used to generate a symetric encryption key for the coookie data.
37
- # @param same_site [Symbol, String] Controls how the cookie is provided to the site.
38
- # @param expires_after [String] The cache-control header to set for static content.
39
- # @param options [Hash<Symbol,Object>] Additional defaults used for generating the cookie by `Rack::Utils.set_cookie_header!`.
40
- def initialize(app, session_name: RACK_SESSION, secret: nil, expires_after: DEFAULT_EXPIRES_AFTER, update_timeout: DEFAULT_UPDATE_TIMEOUT, secure: false, same_site: :lax, maximum_size: MAXIMUM_SIZE, **options)
41
- @app = app
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: nil,
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: true,
68
- }.merge(options)
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
- def call(env)
95
- session_hash = prepare_session(env)
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
- status, headers, body = @app.call(env)
116
+ response = Response.wrap(@delegate.call(request))
98
117
 
99
- update_session(env, session_hash, headers)
118
+ update_session(request.session, response.headers)
100
119
 
101
- return [status, headers, body]
120
+ return response
102
121
  end
103
122
 
104
123
  protected
105
124
 
106
- def prepare_session(env)
107
- env[RACK_SESSION] = LazyHash.new do
108
- self.load_session_values(env)
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 update_session(env, session_hash, headers)
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(env)
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
- Rack::Utils.set_cookie_header!(headers, @cookie_name, cookie)
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, e].pack("m16m*")
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
- iv, e = data.unpack("m16m*")
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
@@ -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-2025, by Samuel Williams.
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) {"Loading environment at path: #{path.inspect}"}
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) {"Ignoring environment at path: #{path.inspect} (file not found)"}
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 "rack/builder"
7
- require "rack/test"
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
- include Rack::Test::Methods
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 ||= Rack::Builder.parse_file(
22
- File.expand_path("config.ru", @context.root)
23
- ).first
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-2025, by Samuel Williams.
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 file on disk which can be served directly, or passed upstream to sendfile.
18
+ # Represents a local static resource and constructs responses for it.
13
19
  class LocalFile
14
- def initialize(root, path)
15
- @root = root
20
+ # Initialize metadata for a local file.
21
+ # @parameter path [String] The resolved filesystem path.
22
+ def initialize(path)
16
23
  @path = path
17
- @etag = Digest::SHA1.hexdigest("#{File.size(full_path)}#{mtime_date}")
24
+ @stat = File.stat(@path)
25
+ @mtime_date = @stat.mtime.httpdate
18
26
 
19
- @range = nil
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
- File.mtime(full_path).httpdate
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
- File.size(full_path)
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
- alias size bytesize
50
-
51
- def each
52
- File.open(full_path, "rb") do |file|
53
- file.seek(@range.begin)
54
- remaining = @range.end - @range.begin+1
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 etags = env["HTTP_IF_NONE_MATCH"]
72
- etags = etags.split(/\s*,\s*/)
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 = Rack::CONTENT_LENGTH
80
- CONTENT_RANGE = "Content-Range".freeze
61
+ CONTENT_LENGTH = "content-length".freeze
62
+ CONTENT_RANGE = "content-range".freeze
81
63
 
82
- def serve(env, response_headers)
83
- ranges = Rack::Utils.get_byte_ranges(env["HTTP_RANGE"], size)
84
- response = [200, response_headers, self]
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
- response[0] = 200
92
- response[1][CONTENT_LENGTH] = size.to_s
93
- @range = 0...size
77
+ status = 200
78
+ response_headers[CONTENT_LENGTH] = size.to_s
79
+ range = nil
94
80
  else
95
81
  # Partial content:
96
- @range = ranges[0]
97
- partial_size = @range.size
82
+ range = ranges[0]
83
+ partial_size = range.size
98
84
 
99
- response[0] = 206
100
- response[1][CONTENT_LENGTH] = partial_size.to_s
101
- response[1][CONTENT_RANGE] = "bytes #{@range.min}-#{@range.max}/#{size}"
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
- return response
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