utopia 3.0.4 → 3.0.6

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.
data/lib/utopia/path.rb CHANGED
@@ -1,12 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2009-2025, by Samuel Williams.
4
+ # Copyright, 2009-2026, by Samuel Williams.
5
5
 
6
6
  require "protocol/url/path"
7
7
 
8
8
  module Utopia
9
- # Represents a path as an array of path components. Useful for efficient URL manipulation.
9
+ # Represents an application path as a traversal through a tree.
10
+ #
11
+ # Each component names a node and `/` represents the edge between adjacent nodes. A leading empty component anchors the traversal at the root, while a trailing empty component preserves an explicit final edge and denotes a directory:
12
+ #
13
+ # - `["foo", "bar"]` represents `foo/bar`.
14
+ # - `["", "foo", "bar"]` represents `/foo/bar`.
15
+ # - `["", "foo", "bar", ""]` represents `/foo/bar/`.
16
+ #
17
+ # The structural root is represented by `[""]` and contains no traversed edge. It is intentionally distinct from parsing `/`, which preserves the explicit edge as `["", ""]`. Both serialize as `/`, but they retain different structural representations. In particular, the structural root maps to an empty local path so it can be resolved relative to an application root.
10
18
  class Path
11
19
  include Comparable
12
20
 
@@ -36,7 +44,7 @@ module Utopia
36
44
  @components.empty?
37
45
  end
38
46
 
39
- # Construct the root path.
47
+ # Construct the structural root path without an explicit trailing separator.
40
48
  # @returns [Path] The root path.
41
49
  def self.root
42
50
  self.new([""])
@@ -203,12 +211,6 @@ module Utopia
203
211
  end
204
212
  end
205
213
 
206
- # Remove the first component when this path is relative.
207
- # @returns [String | Nil] The removed component, or `nil` when the path is absolute.
208
- def to_relative!
209
- @components.shift if relative?
210
- end
211
-
212
214
  # Convert this object to a string.
213
215
  # @returns [String] The resulting string.
214
216
  def to_str
@@ -532,11 +534,4 @@ module Utopia
532
534
  return index
533
535
  end
534
536
  end
535
-
536
- # Coerce a value into a {Path}.
537
- # @parameter path [Utopia::Path | String] The path.
538
- # @returns [Path | Nil] The coerced path.
539
- def self.Path(path)
540
- Path.create(path)
541
- end
542
537
  end
@@ -117,28 +117,13 @@ module Utopia
117
117
  @query_parameters ||= parse_query_parameters(self.url.query)
118
118
  end
119
119
 
120
- private def parse_cookies(cookie_header)
121
- cookies = {}
122
-
123
- return cookies unless cookie_header
124
-
125
- if cookie_header.respond_to?(:to_str)
126
- cookie_header = cookie_header.to_str
127
- else
128
- cookie_header = cookie_header.to_s
129
- end
130
-
131
- cookie_header.split(/;\s*/).each do |pair|
132
- key, value = pair.split("=", 2)
133
- cookies[key] = value || ""
134
- end
135
-
136
- return cookies
137
- end
138
-
139
120
  # Decoded request cookies.
140
121
  def cookies
141
- @cookies ||= parse_cookies(self.headers["cookie"])
122
+ @cookies ||= if cookie_header = self.headers["cookie"]
123
+ cookie_header.to_h.transform_values(&:value)
124
+ else
125
+ {}
126
+ end
142
127
  end
143
128
 
144
129
  # The request user agent.
@@ -16,10 +16,6 @@ module Utopia
16
16
  @loader = block
17
17
  end
18
18
 
19
- # The loaded session values, if already loaded.
20
- # @returns [Hash | Nil] The loaded values.
21
- attr :values
22
-
23
19
  # Fetch a value by key, loading the hash if necessary.
24
20
  # @parameter key [Object] The key.
25
21
  # @returns [Object | Nil] The value.
@@ -66,10 +62,26 @@ module Utopia
66
62
  @changed
67
63
  end
68
64
 
69
- # Load and return the underlying values.
70
- # @returns [Hash] The loaded values.
71
- def load!
72
- @values ||= @loader.call
65
+ # The current time for session expiry and persistence.
66
+ # @returns [Time] The current time in UTC.
67
+ def now
68
+ Time.now.utc
69
+ end
70
+
71
+ # Persist the session values if they have changed or require updating.
72
+ # @parameter timeout [Numeric | Nil] The maximum age before an update is required.
73
+ # @yields {|values, updated_at| ...} The loaded values and their update time.
74
+ # @returns [Object | Nil] The result of the block if persistence was required.
75
+ def persist(timeout = nil)
76
+ return unless needs_update?(timeout)
77
+
78
+ values = load!
79
+ updated_at = values[:updated_at] = now
80
+
81
+ result = yield(values, updated_at)
82
+ @changed = false
83
+
84
+ return result
73
85
  end
74
86
 
75
87
  # Check whether the underlying values have been loaded.
@@ -88,11 +100,18 @@ module Utopia
88
100
  # We want to be careful here and not call load! which isn't cheap operation.
89
101
  if timeout and @values and updated_at = @values[:updated_at]
90
102
  # If the last update was too long ago, we need update:
91
- return true if updated_at < (Time.now - timeout)
103
+ return true if updated_at < (now - timeout)
92
104
  end
93
105
 
94
106
  return false
95
107
  end
108
+
109
+ private
110
+
111
+ # Load and return the underlying values.
112
+ def load!
113
+ @values ||= @loader.call(now)
114
+ end
96
115
  end
97
116
  end
98
117
  end
@@ -24,12 +24,14 @@ module Utopia
24
24
  class PayloadError < StandardError
25
25
  end
26
26
 
27
- MAXIMUM_SIZE = 1024*32
27
+ SIZE_LIMIT = 1024*32
28
28
 
29
29
  SECRET_KEY = "UTOPIA_SESSION_SECRET".freeze
30
30
 
31
31
  SESSION_KEY = "utopia.session".freeze
32
- CIPHER_ALGORITHM = "aes-256-cbc"
32
+ CIPHER_ALGORITHM = "aes-256-gcm"
33
+ PAYLOAD_VERSION = "v1".freeze
34
+ AUTHENTICATION_TAG_SIZE = 16
33
35
 
34
36
  # The session will expire if no requests were made within 24 hours:
35
37
  DEFAULT_EXPIRES_AFTER = 3600*24
@@ -48,8 +50,8 @@ module Utopia
48
50
  # @param http_only [Boolean] Whether client-side scripts may access the cookie.
49
51
  # @param same_site [Symbol | String | Boolean | Nil] Controls whether the cookie is sent with cross-site requests.
50
52
  # @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
+ # @param size_limit [Integer | Nil] The encoded session payload size limit.
54
+ 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, size_limit: SIZE_LIMIT)
53
55
  super(app)
54
56
 
55
57
  @session_name = session_name
@@ -61,6 +63,7 @@ module Utopia
61
63
 
62
64
  # This generates a 32-byte key suitable for aes.
63
65
  @key = Digest::SHA2.digest(secret)
66
+ @authentication_context = "#{@cookie_name}\0#{PAYLOAD_VERSION}".b.freeze
64
67
 
65
68
  @expires_after = expires_after
66
69
  @update_timeout = update_timeout
@@ -82,7 +85,7 @@ module Utopia
82
85
  }
83
86
 
84
87
  @serialization = Serialization.new
85
- @maximum_size = maximum_size
88
+ @size_limit = size_limit
86
89
  end
87
90
 
88
91
  attr :cookie_name
@@ -100,6 +103,7 @@ module Utopia
100
103
 
101
104
  @cookie_name.freeze
102
105
  @key.freeze
106
+ @authentication_context.freeze
103
107
  @expires_after.freeze
104
108
  @update_timeout.freeze
105
109
  @cookie_defaults.freeze
@@ -139,59 +143,53 @@ module Utopia
139
143
  end
140
144
 
141
145
  def prepare_session(request)
142
- LazyHash.new do
143
- self.load_session_values(request)
146
+ LazyHash.new do |now|
147
+ self.load_session_values(request, now)
144
148
  end
145
149
  end
146
150
 
147
151
  def update_session(session_hash, headers)
148
- if session_hash.needs_update?(@update_timeout)
149
- values = session_hash.values
150
-
151
- values[:updated_at] = Time.now.utc
152
-
153
- data = encrypt(session_hash.values)
154
-
155
- commit(data, values[:updated_at], headers)
152
+ session_hash.persist(@update_timeout) do |values, updated_at|
153
+ commit(encrypt(values), updated_at, headers)
156
154
  end
157
155
  end
158
156
 
159
157
  # Constructs a valid session for the given request. These fields must match as per the checks performed in `valid_session?`:
160
- def build_initial_session(request)
158
+ def build_initial_session(request, now)
161
159
  {
162
160
  user_agent: request.user_agent,
163
- created_at: Time.now.utc,
164
- updated_at: Time.now.utc,
161
+ created_at: now,
162
+ updated_at: now,
165
163
  }
166
164
  end
167
165
 
168
166
  # Load session from user supplied cookie. If the data is invalid or otherwise fails validation, `build_iniital_session` is invoked.
169
167
  # @return hash of values.
170
- def load_session_values(request)
168
+ def load_session_values(request, now)
171
169
  # Decrypt the data from the user if possible:
172
170
  if data = request.cookies[@cookie_name]
173
171
  begin
174
172
  if values = decrypt(data)
175
- validate_session!(request, values)
173
+ validate_session!(request, values, now)
176
174
 
177
175
  return values
178
176
  end
179
- rescue => error
180
- Console.error(self, error)
177
+ rescue PayloadError => error
178
+ Console.debug(self, "Discarding invalid session cookie!", exception: error)
181
179
  end
182
180
  end
183
181
 
184
182
  # If we couldn't create a session
185
- return build_initial_session(request)
183
+ return build_initial_session(request, now)
186
184
  end
187
185
 
188
- def validate_session!(request, values)
186
+ def validate_session!(request, values, now)
189
187
  if values[:user_agent] != request.user_agent
190
188
  raise PayloadError, "Invalid session because supplied user agent #{request.user_agent.inspect} does not match session user agent #{values[:user_agent].inspect}!"
191
189
  end
192
190
 
193
191
  if expires_at = expires(values[:updated_at])
194
- if expires_at < Time.now.utc
192
+ if expires_at < now
195
193
  raise PayloadError, "Expired session cookie, user agent submitted a cookie that should have expired at #{expires_at}."
196
194
  end
197
195
  end
@@ -199,7 +197,7 @@ module Utopia
199
197
  return true
200
198
  end
201
199
 
202
- def expires(updated_at=Time.now.utc)
200
+ def expires(updated_at)
203
201
  if @expires_after
204
202
  return updated_at + @expires_after
205
203
  end
@@ -253,38 +251,65 @@ module Utopia
253
251
  end
254
252
 
255
253
  def encrypt(hash)
256
- c = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
257
- c.encrypt
254
+ cipher = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
255
+ cipher.encrypt
256
+
257
+ cipher.key = @key
258
+ cipher.iv = initialization_vector = cipher.random_iv
259
+ cipher.auth_data = @authentication_context
260
+
261
+ encrypted_data = cipher.update(@serialization.dump(hash))
262
+ encrypted_data << cipher.final
258
263
 
259
- # your pass is what is used to encrypt/decrypt
260
- c.key = @key
261
- c.iv = iv = c.random_iv
264
+ payload = initialization_vector + encrypted_data + cipher.auth_tag(AUTHENTICATION_TAG_SIZE)
265
+ data = "#{PAYLOAD_VERSION}.#{[payload].pack("m0")}"
262
266
 
263
- e = c.update(@serialization.dump(hash))
264
- e << c.final
267
+ validate_size!(data)
265
268
 
266
- return [iv + e].pack("m0")
269
+ return data
267
270
  end
268
271
 
269
272
  def decrypt(data)
270
- if @maximum_size and data.bytesize > @maximum_size
271
- raise PayloadError, "Session payload size #{data.bytesize}bytes exceeds maximum allowed size #{@maximum_size}bytes!"
272
- end
273
-
274
- payload = data.unpack1("m0")
275
- iv = payload.byteslice(0, 16)
276
- e = payload.byteslice(16..)
277
-
278
- c = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
279
- c.decrypt
273
+ validate_size!(data)
280
274
 
281
- c.key = @key
282
- c.iv = iv
275
+ version, encoded_payload = data.split(".", 2)
283
276
 
284
- d = c.update(e)
285
- d << c.final
277
+ if version != PAYLOAD_VERSION or encoded_payload.nil?
278
+ raise PayloadError, "Unsupported session payload format!"
279
+ end
286
280
 
287
- return @serialization.load(d)
281
+ begin
282
+ cipher = OpenSSL::Cipher.new(CIPHER_ALGORITHM)
283
+ payload = encoded_payload.unpack1("m0")
284
+ minimum_size = cipher.iv_len + AUTHENTICATION_TAG_SIZE
285
+
286
+ if payload.bytesize < minimum_size
287
+ raise PayloadError, "Invalid session payload!"
288
+ end
289
+
290
+ initialization_vector = payload.byteslice(0, cipher.iv_len)
291
+ encrypted_data = payload.byteslice(cipher.iv_len, payload.bytesize - minimum_size)
292
+ authentication_tag = payload.byteslice(-AUTHENTICATION_TAG_SIZE, AUTHENTICATION_TAG_SIZE)
293
+
294
+ cipher.decrypt
295
+ cipher.key = @key
296
+ cipher.iv = initialization_vector
297
+ cipher.auth_tag = authentication_tag
298
+ cipher.auth_data = @authentication_context
299
+
300
+ decrypted_data = cipher.update(encrypted_data)
301
+ decrypted_data << cipher.final
302
+
303
+ return @serialization.load(decrypted_data)
304
+ rescue ArgumentError, OpenSSL::Cipher::CipherError
305
+ raise PayloadError, "Invalid session payload!"
306
+ end
307
+ end
308
+
309
+ def validate_size!(data)
310
+ if @size_limit and data.bytesize > @size_limit
311
+ raise PayloadError, "Session payload size #{data.bytesize}bytes exceeds size limit #{@size_limit}bytes!"
312
+ end
288
313
  end
289
314
  end
290
315
  end
@@ -69,9 +69,13 @@ module Utopia
69
69
  ranges = byte_ranges(request)
70
70
  size = bytesize
71
71
 
72
- # puts "Requesting ranges: #{ranges.inspect} (#{size})"
73
-
74
- if ranges == nil or ranges.size != 1
72
+ # A valid byte-range request with no satisfiable ranges cannot be fulfilled:
73
+ if ranges && ranges.empty?
74
+ response_headers[CONTENT_LENGTH] = "0"
75
+ response_headers[CONTENT_RANGE] = "bytes */#{size}"
76
+
77
+ return Response[416, response_headers, []]
78
+ elsif ranges == nil or ranges.size != 1
75
79
  # No ranges, or multiple ranges (which we don't support).
76
80
  # TODO: Support multiple byte-ranges, for now just send entire file:
77
81
  status = 200
@@ -11,8 +11,6 @@ require_relative "local_file"
11
11
  require_relative "mime_types"
12
12
  require_relative "../localization/resolver"
13
13
 
14
- require "traces/provider"
15
-
16
14
  module Utopia
17
15
  module Static
18
16
  DEFAULT_CACHE_CONTROL = "public, max-age=3600".freeze
@@ -138,15 +136,5 @@ module Utopia
138
136
  end
139
137
  end
140
138
 
141
- Traces::Provider(Static) do
142
- def respond(request, path, extension, content_type, localization: request.localization)
143
- attributes = {
144
- path: path,
145
- locale: localization&.locale,
146
- }
147
-
148
- Traces.trace("utopia.static.respond", attributes: attributes){super}
149
- end
150
- end
151
139
  end
152
140
  end
@@ -5,5 +5,5 @@
5
5
 
6
6
  # @namespace
7
7
  module Utopia
8
- VERSION = "3.0.4"
8
+ VERSION = "3.0.6"
9
9
  end
data/readme.md CHANGED
@@ -31,6 +31,19 @@ Please see the [project documentation](https://socketry.github.io/utopia/) for m
31
31
 
32
32
  Please see the [project releases](https://socketry.github.io/utopia/releases/index) for all releases.
33
33
 
34
+ ### v3.0.6
35
+
36
+ - [JavaScript Packages](https://socketry.github.io/utopia/releases/index#javascript-packages)
37
+
38
+ ### v3.0.5
39
+
40
+ - **Breaking** Remove support for JavaScript packages installed in `lib/components`; use `node_modules` instead.
41
+ - **Breaking** Expose <code class="language-ruby">Utopia::Content::Middleware\#links</code> as the content link resolver rather than an indexed lookup method.
42
+ - **Security** Authenticate encrypted session cookies using AES-256-GCM. Existing session cookies are invalidated.
43
+ - Constrain content node local paths to the configured content root.
44
+ - **Security** Redact sensitive exception report fields and make bounded request body attachments opt-in.
45
+ - Return `416 Range Not Satisfiable` for unsatisfiable static file byte ranges.
46
+
34
47
  ### v3.0.0
35
48
 
36
49
  The 3.0.x series is considered a development release while the protocol HTTP application and controller interfaces stabilize.
data/releases.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Releases
2
2
 
3
+ ## v3.0.6
4
+
5
+ ### JavaScript Packages
6
+
7
+ Utopia now depends on `bake-node` for JavaScript dependency installation and static package projection. `Utopia::Components` and `utopia:components:update` have been removed. Replace the old task with `bundle exec bake node:packages:static`, and migrate package selection from `utopia.components` to `bake-node.packages` in `package.json`.
8
+
9
+ Use `Utopia::ImportMap.load_manifest("public/_components")` to load the generated browser import mappings directly from the Bake Node manifest.
10
+
11
+ ## v3.0.5
12
+
13
+ - **Breaking** Remove support for JavaScript packages installed in `lib/components`; use `node_modules` instead.
14
+ - **Breaking** Expose {ruby Utopia::Content::Middleware\#links} as the content link resolver rather than an indexed lookup method.
15
+ - **Security** Authenticate encrypted session cookies using AES-256-GCM. Existing session cookies are invalidated.
16
+ - Constrain content node local paths to the configured content root.
17
+ - **Security** Redact sensitive exception report fields and make bounded request body attachments opt-in.
18
+ - Return `416 Range Not Satisfiable` for unsatisfiable static file byte ranges.
19
+
3
20
  ## v3.0.0
4
21
 
5
22
  The 3.0.x series is considered a development release while the protocol HTTP application and controller interfaces stabilize.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: utopia
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.4
4
+ version: 3.0.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -57,6 +57,20 @@ dependencies:
57
57
  - - "~>"
58
58
  - !ruby/object:Gem::Version
59
59
  version: '0.20'
60
+ - !ruby/object:Gem::Dependency
61
+ name: bake-node
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ type: :runtime
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
60
74
  - !ruby/object:Gem::Dependency
61
75
  name: concurrent-ruby
62
76
  requirement: !ruby/object:Gem::Requirement
@@ -161,14 +175,14 @@ dependencies:
161
175
  requirements:
162
176
  - - "~>"
163
177
  - !ruby/object:Gem::Version
164
- version: '0.70'
178
+ version: '0.71'
165
179
  type: :runtime
166
180
  prerelease: false
167
181
  version_requirements: !ruby/object:Gem::Requirement
168
182
  requirements:
169
183
  - - "~>"
170
184
  - !ruby/object:Gem::Version
171
- version: '0.70'
185
+ version: '0.71'
172
186
  - !ruby/object:Gem::Dependency
173
187
  name: protocol-media
174
188
  requirement: !ruby/object:Gem::Requirement
@@ -272,7 +286,6 @@ extensions: []
272
286
  extra_rdoc_files: []
273
287
  files:
274
288
  - bake/utopia.rb
275
- - bake/utopia/components.rb
276
289
  - bake/utopia/environment.rb
277
290
  - bake/utopia/server.rb
278
291
  - bake/utopia/shell.rb
@@ -285,9 +298,11 @@ files:
285
298
  - context/server-setup.md
286
299
  - context/updating-utopia.md
287
300
  - context/what-is-xnode.md
301
+ - lib/traces/provider/utopia.rb
302
+ - lib/traces/provider/utopia/content/middleware.rb
303
+ - lib/traces/provider/utopia/static/middleware.rb
288
304
  - lib/utopia.rb
289
305
  - lib/utopia/application.rb
290
- - lib/utopia/components.rb
291
306
  - lib/utopia/content.rb
292
307
  - lib/utopia/content/builder.rb
293
308
  - lib/utopia/content/document.rb
@@ -311,6 +326,7 @@ files:
311
326
  - lib/utopia/controller/rewrite.rb
312
327
  - lib/utopia/controller/variables.rb
313
328
  - lib/utopia/exceptions.rb
329
+ - lib/utopia/exceptions/application_errors.rb
314
330
  - lib/utopia/exceptions/handler.rb
315
331
  - lib/utopia/exceptions/mailer.rb
316
332
  - lib/utopia/extensions/array_split.rb
metadata.gz.sig CHANGED
Binary file
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Released under the MIT License.
4
- # Copyright, 2026, by Samuel Williams.
5
-
6
- NPM = ENV["NPM"] || "npm"
7
-
8
- # Update public components from production JavaScript packages.
9
- #
10
- # Packages are copied from their `dist` directory when present, or otherwise
11
- # from the package root. The `utopia.components` section of `package.json` can
12
- # specify per-package `include` patterns to select only required files.
13
- #
14
- # @parameter root [String] The project root directory.
15
- def update(root: context.root)
16
- require "json"
17
- require "open3"
18
- require "utopia/components"
19
-
20
- components = Utopia::Components.new(root)
21
- production_packages = fetch_production_packages(components.package_root)
22
-
23
- components.update(production_packages)
24
- end
25
-
26
- private
27
-
28
- def fetch_production_packages(package_root)
29
- stdout, _status = Open3.capture2(NPM, "ls", "--production", "--json", chdir: package_root.to_s)
30
- json = JSON.parse(stdout)
31
-
32
- flatten_package_dependencies(json).sort.uniq
33
- end
34
-
35
- def flatten_package_dependencies(json, into = [])
36
- if json["dependencies"]
37
- json["dependencies"].each do |name, details|
38
- into << name
39
- flatten_package_dependencies(details, into)
40
- end
41
- end
42
-
43
- return into
44
- end