utopia 2.32.1 → 3.0.1

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 (86) 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/context/what-is-xnode.md +6 -0
  12. data/lib/utopia/application.rb +83 -0
  13. data/lib/utopia/content/builder.rb +25 -0
  14. data/lib/utopia/content/document.rb +64 -6
  15. data/lib/utopia/content/link.rb +50 -5
  16. data/lib/utopia/content/links.rb +44 -7
  17. data/lib/utopia/content/markup.rb +74 -0
  18. data/lib/utopia/content/middleware.rb +58 -22
  19. data/lib/utopia/content/namespace.rb +10 -1
  20. data/lib/utopia/content/node.rb +66 -4
  21. data/lib/utopia/content/response.rb +12 -3
  22. data/lib/utopia/content.rb +3 -0
  23. data/lib/utopia/controller/actions.md +4 -4
  24. data/lib/utopia/controller/actions.rb +51 -3
  25. data/lib/utopia/controller/base.rb +72 -29
  26. data/lib/utopia/controller/middleware.rb +20 -15
  27. data/lib/utopia/controller/respond.rb +36 -32
  28. data/lib/utopia/controller/responder.rb +106 -45
  29. data/lib/utopia/controller/result.rb +11 -0
  30. data/lib/utopia/controller/rewrite.rb +39 -0
  31. data/lib/utopia/controller/variables.rb +15 -3
  32. data/lib/utopia/controller.rb +2 -0
  33. data/lib/utopia/exceptions/handler.rb +20 -11
  34. data/lib/utopia/exceptions/mailer.rb +56 -51
  35. data/lib/utopia/extensions/array_split.rb +6 -0
  36. data/lib/utopia/extensions/date_comparisons.rb +6 -0
  37. data/lib/utopia/http.rb +11 -48
  38. data/lib/utopia/import_map.rb +19 -11
  39. data/lib/utopia/localization/locales.rb +60 -0
  40. data/lib/utopia/localization/middleware.rb +107 -73
  41. data/lib/utopia/localization/preferences.rb +76 -0
  42. data/lib/utopia/localization/resolver.rb +47 -0
  43. data/lib/utopia/localization.rb +6 -0
  44. data/lib/utopia/middleware.rb +3 -4
  45. data/lib/utopia/path/matcher.rb +15 -0
  46. data/lib/utopia/path.rb +146 -10
  47. data/lib/utopia/redirection/client_redirect.rb +87 -0
  48. data/lib/utopia/redirection/directory_index.rb +41 -0
  49. data/lib/utopia/redirection/errors.rb +78 -0
  50. data/lib/utopia/redirection/moved.rb +52 -0
  51. data/lib/utopia/redirection/request_failure.rb +26 -0
  52. data/lib/utopia/redirection/rewrite.rb +42 -0
  53. data/lib/utopia/redirection.rb +7 -171
  54. data/lib/utopia/request.rb +202 -0
  55. data/lib/utopia/response.rb +73 -0
  56. data/lib/utopia/session/lazy_hash.rb +27 -1
  57. data/lib/utopia/session/middleware.rb +103 -30
  58. data/lib/utopia/session/serialization.rb +8 -0
  59. data/lib/utopia/session.rb +5 -0
  60. data/lib/utopia/setup.rb +23 -3
  61. data/lib/utopia/shell.rb +28 -7
  62. data/lib/utopia/static/local_file.rb +84 -61
  63. data/lib/utopia/static/middleware.rb +65 -33
  64. data/lib/utopia/static/mime_types.rb +38 -29
  65. data/lib/utopia/static.rb +2 -0
  66. data/lib/utopia/version.rb +3 -2
  67. data/lib/utopia.rb +1 -1
  68. data/license.md +1 -1
  69. data/readme.md +30 -5
  70. data/releases.md +12 -1
  71. data/setup/site/bake.rb +1 -1
  72. data/setup/site/config/application.rb +51 -0
  73. data/setup/site/config/serve.rb +8 -0
  74. data/setup/site/falcon.rb +17 -4
  75. data/setup/site/fixtures/website.rb +27 -11
  76. data/setup/site/gems.rb +1 -3
  77. data/setup/site/lib/readme.txt +1 -1
  78. data/setup/site/pages/welcome/index.xnode +3 -3
  79. data/setup/site/readme.md +0 -3
  80. data/setup/site/test/website.rb +2 -2
  81. data.tar.gz.sig +0 -0
  82. metadata +60 -34
  83. metadata.gz.sig +0 -0
  84. data/lib/utopia/localization/wrapper.rb +0 -52
  85. data/setup/site/Guardfile +0 -12
  86. data/setup/site/config.ru +0 -49
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2009-2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require_relative "../middleware"
7
- require_relative "../localization"
7
+ require_relative "../request"
8
+ require_relative "../response"
8
9
 
9
10
  require_relative "local_file"
10
11
  require_relative "mime_types"
12
+ require_relative "../localization/resolver"
11
13
 
12
14
  require "traces/provider"
13
15
 
@@ -16,19 +18,24 @@ module Utopia
16
18
  DEFAULT_CACHE_CONTROL = "public, max-age=3600".freeze
17
19
 
18
20
  # A middleware which serves static files from the specified root directory.
19
- class Middleware
21
+ class Middleware < Protocol::HTTP::Middleware
22
+ include Localization::Resolver
23
+
20
24
  # @param root [String] The root directory to serve files from.
21
- # @param types [Array] The mime-types (and file extensions) to recognize/serve.
25
+ # @param types [Array] The file extensions and named groups to recognize/serve.
22
26
  # @param cache_control [String] The cache-control header to set for static content.
23
27
  def initialize(app, root: Utopia::default_root, types: MIME_TYPES[:default], cache_control: DEFAULT_CACHE_CONTROL)
24
- @app = app
25
- @root = root
28
+ super(app)
29
+
30
+ @root = File.expand_path(root)
26
31
 
27
32
  @extensions = MimeTypeLoader.extensions_for(types)
28
33
 
29
34
  @cache_control = cache_control
30
35
  end
31
36
 
37
+ # Freeze this object and its internal state.
38
+ # @returns [self] This object.
32
39
  def freeze
33
40
  return self if frozen?
34
41
 
@@ -39,12 +46,12 @@ module Utopia
39
46
  super
40
47
  end
41
48
 
42
- def fetch_file(path)
43
- # We need file_path to be an absolute path for X-Sendfile to work correctly.
44
- file_path = File.join(@root, path.components)
45
-
46
- if File.exist?(file_path)
47
- return LocalFile.new(@root, path)
49
+ # Open metadata for an existing file under the static root.
50
+ # @parameter local_path [String] The resolved filesystem path.
51
+ # @returns [LocalFile | Nil] The local file, or `nil` when it does not exist.
52
+ private def fetch_file(local_path)
53
+ if File.file?(local_path)
54
+ return LocalFile.new(local_path)
48
55
  else
49
56
  return nil
50
57
  end
@@ -52,12 +59,16 @@ module Utopia
52
59
 
53
60
  attr :extensions
54
61
 
55
- LAST_MODIFIED = "Last-Modified".freeze
62
+ LAST_MODIFIED = "last-modified".freeze
56
63
  CONTENT_TYPE = HTTP::CONTENT_TYPE
57
64
  CACHE_CONTROL = HTTP::CACHE_CONTROL
58
- ETAG = "ETag".freeze
59
- ACCEPT_RANGES = "Accept-Ranges".freeze
65
+ ETAG = "etag".freeze
66
+ ACCEPT_RANGES = "accept-ranges".freeze
60
67
 
68
+ # Build response headers for the given file.
69
+ # @parameter file [LocalFile] The file.
70
+ # @parameter content_type [String] The content type.
71
+ # @returns [Hash(String, String)] The response headers.
61
72
  def response_headers_for(file, content_type)
62
73
  if @cache_control.respond_to?(:call)
63
74
  cache_control = @cache_control.call(file)
@@ -74,46 +85,67 @@ module Utopia
74
85
  }
75
86
  end
76
87
 
77
- def respond(env, path_info, extension)
78
- path = Path[path_info].simplify
88
+ # Respond.
89
+ # @parameter request [Utopia::Request] The request.
90
+ # @parameter path [Protocol::URL::Path] The request path to serve.
91
+ # @parameter extension [String] The file extension.
92
+ # @parameter content_type [String] The file media type.
93
+ # @parameter localization [Utopia::Localization::Preferences | Nil] The selected localization.
94
+ # @returns [Protocol::HTTP::Response] The response.
95
+ def respond(request, path, extension, content_type, localization: request.localization)
96
+ local_path = path.local_path(@root)
79
97
 
80
- if locale = env[Localization::CURRENT_LOCALE_KEY]
81
- path.last.insert(path.last.rindex(".") || -1, ".#{locale}")
98
+ if locale = localization&.locale
99
+ local_path = local_path.delete_suffix(extension) + ".#{locale}#{extension}"
82
100
  end
83
101
 
84
- if file = fetch_file(path)
85
- response_headers = self.response_headers_for(file, @extensions[extension])
102
+ if file = fetch_file(local_path)
103
+ response_headers = self.response_headers_for(file, content_type)
86
104
 
87
- if file.modified?(env)
88
- return file.serve(env, response_headers)
105
+ if file.modified?(request)
106
+ return file.serve(request, response_headers)
89
107
  else
90
- return [304, response_headers, []]
108
+ return Response[304, response_headers, []]
91
109
  end
92
110
  end
93
111
  end
94
112
 
95
- def call(env)
96
- path_info = env[Rack::PATH_INFO]
97
- extension = File.extname(path_info)
113
+ # Serve a recognized static file or pass the request to the next middleware.
114
+ # @parameter request [Utopia::Request] The request.
115
+ # @returns [Protocol::HTTP::Response] The static-file or downstream response.
116
+ def call(request)
117
+ case request.method
118
+ when Protocol::HTTP::Methods::GET, Protocol::HTTP::Methods::HEAD
119
+ else
120
+ return @delegate.call(request)
121
+ end
122
+
123
+ path = request.url.path
124
+ extension = File.extname(path.basename)
98
125
 
99
- if @extensions.key?(extension.downcase)
100
- if response = self.respond(env, path_info, extension)
126
+ if content_type = @extensions[extension.downcase]
127
+ response = resolve_localized(request) do |localization|
128
+ self.respond(request, path, extension, content_type, localization: localization)
129
+ end
130
+
131
+ if response
101
132
  return response
102
133
  end
103
134
  end
104
135
 
105
136
  # else if no file was found:
106
- return @app.call(env)
137
+ return @delegate.call(request)
107
138
  end
108
139
  end
109
140
 
110
141
  Traces::Provider(Static) do
111
- def respond(env, path_info, extension)
142
+ def respond(request, path, extension, content_type, localization: request.localization)
112
143
  attributes = {
113
- path_info: path_info,
144
+ path: path,
145
+ locale: localization&.locale,
114
146
  }
115
147
 
116
- Traces.trace("utopia.static.respond", attributes: attributes) {super}
148
+ Traces.trace("utopia.static.respond", attributes: attributes){super}
117
149
  end
118
150
  end
119
151
  end
@@ -1,38 +1,29 @@
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
- require "mime/types"
6
+ require "protocol/media/registry"
7
7
 
8
8
  module Utopia
9
9
  # A middleware which serves static files from the specified root directory.
10
10
  module Static
11
11
  # Default mime-types which are common for files served over HTTP:
12
12
  MIME_TYPES = {
13
- :xiph => {
14
- "ogx" => "application/ogg",
15
- "ogv" => "video/ogg",
16
- "oga" => "audio/ogg",
17
- "ogg" => "audio/ogg",
18
- "spx" => "audio/ogg",
19
- "flac" => "audio/flac",
20
- "anx" => "application/annodex",
21
- "axa" => "audio/annodex",
22
- "xspf" => "application/xspf+xml",
23
- },
13
+ :xiph => [
14
+ "ogx", "ogv", "oga", "ogg", "spx", "flac", "xspf"
15
+ ],
24
16
  :media => [
25
- :xiph, "mp3", "mp4", "wav", "aiff", "aac", "webm", "mov", "avi", "wmv", "mpg", "m3u8", "ts"
17
+ :xiph, "mp3", "mp4", "wav", "aiff", "aac", "webm", "weba", "mov", "avi", "wmv", "mpg", "m3u8", "ts"
26
18
  ],
27
19
  :text => [
28
- "html", "css", "js", ["map", "application/json"], "txt", "rtf", "xml", "pdf"
20
+ "html", "css", "js", "map", "txt", "rtf", "xml", "pdf"
29
21
  ],
30
22
  :fonts => [
31
- "otf", ["eot", "application/vnd.ms-fontobject"], "ttf", "woff", "woff2"
23
+ "otf", "eot", "ttf", "woff", "woff2"
32
24
  ],
33
25
  :archive => [
34
- "zip", "tar", "tgz", "tar.gz", "tar.bz2", ["dmg", "application/x-apple-diskimage"],
35
- ["torrent", "application/x-bittorrent"]
26
+ "zip", "tar", "tgz", "gz", "bz2", "dmg", "torrent"
36
27
  ],
37
28
  :images => [
38
29
  "png", "gif", "jpeg", "tiff", "svg", "webp"
@@ -44,6 +35,8 @@ module Utopia
44
35
 
45
36
  # A class to assist with loading mime-type metadata.
46
37
  class MimeTypeLoader
38
+ # Initialize an empty extension map backed by named MIME type groups.
39
+ # @parameter library [Object] The library.
47
40
  def initialize(library)
48
41
  @extensions = {}
49
42
  @library = library
@@ -51,37 +44,53 @@ module Utopia
51
44
 
52
45
  attr :extensions
53
46
 
47
+ # Find extensions associated with the given MIME types.
48
+ # @parameter types [Array] The MIME type definitions to expand.
49
+ # @parameter library [Hash] The named MIME type groups.
50
+ # @returns [Hash(String, String)] A mapping from file extensions to content types.
54
51
  def self.extensions_for(types, library = MIME_TYPES)
55
52
  loader = self.new(library)
56
53
  loader.expand(types)
57
54
  return loader.extensions
58
55
  end
59
56
 
60
- def extract_extensions(mime_types)
61
- mime_types.select{|mime_type| !mime_type.obsolete?}.each do |mime_type|
62
- mime_type.extensions.each do |ext|
63
- @extensions["." + ext] ||= mime_type.content_type
64
- end
57
+ # Add an extension using its registered media type.
58
+ # @parameter extension [String] The file extension.
59
+ # @returns [Protocol::Media::Registry::Record] The registered media type record.
60
+ def add_extension(extension)
61
+ # Normalize optional leading dots and case before constructing the File.extname-style key:
62
+ extension = extension.delete_prefix(".").downcase
63
+
64
+ if record = Protocol::Media::Registry.for_extension(extension)
65
+ @extensions["." + extension] = record.type.to_s
66
+ return record
65
67
  end
68
+
69
+ raise ExpansionError.new("Unknown file extension: #{extension.inspect}")
66
70
  end
67
71
 
72
+ # Raised when expansion processing fails.
68
73
  class ExpansionError < ArgumentError
69
74
  end
70
75
 
76
+ # Expand named groups, file extensions, and explicit extension pairs into extension mappings.
77
+ # @parameter types [Array] The MIME type definitions.
78
+ # @returns [Array] The supplied definitions.
79
+ # @raises [ExpansionError] If a definition cannot be expanded.
71
80
  def expand(types)
72
81
  types.each do |type|
73
82
  case type
74
83
  when Symbol
75
- self.expand(MIME_TYPES[type])
84
+ self.expand(@library.fetch(type))
76
85
  when Array
77
86
  @extensions["." + type[0]] = type[1]
78
87
  when String
79
- self.extract_extensions MIME::Types.of(type)
80
- when Regexp
81
- self.extract_extensions MIME::Types[type]
82
- when MIME::Type
83
- self.extract_extensions.call([type])
88
+ self.add_extension(type)
89
+ else
90
+ raise ExpansionError.new("Unsupported MIME type definition: #{type.inspect}")
84
91
  end
92
+ rescue ExpansionError
93
+ raise
85
94
  rescue
86
95
  raise ExpansionError.new("#{self.class.name}: Error while processing #{type.inspect}!")
87
96
  end
data/lib/utopia/static.rb CHANGED
@@ -7,6 +7,8 @@ require_relative "static/middleware"
7
7
 
8
8
  module Utopia
9
9
  module Static
10
+ # Construct static-file middleware.
11
+ # @returns [Static::Middleware] The static-file middleware.
10
12
  def self.new(...)
11
13
  Middleware.new(...)
12
14
  end
@@ -1,8 +1,9 @@
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
+ # @namespace
6
7
  module Utopia
7
- VERSION = "2.32.1"
8
+ VERSION = "3.0.1"
8
9
  end
data/lib/utopia.rb CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  require_relative "utopia/version"
7
7
 
8
+ require_relative "utopia/application"
8
9
  require_relative "utopia/import_map"
9
10
  require_relative "utopia/content"
10
11
  require_relative "utopia/controller"
@@ -12,6 +13,5 @@ require_relative "utopia/exceptions"
12
13
  require_relative "utopia/redirection"
13
14
  require_relative "utopia/static"
14
15
 
15
- # Utopia is a web application framework built on top of Rack.
16
16
  module Utopia
17
17
  end
data/license.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MIT License
2
2
 
3
- Copyright, 2009-2025, by Samuel Williams.
3
+ Copyright, 2009-2026, by Samuel Williams.
4
4
  Copyright, 2015-2019, by Huba Nagy.
5
5
  Copyright, 2020-2025, by Olle Jonsson.
6
6
  Copyright, 2020, by Pierre Montelle.
data/readme.md CHANGED
@@ -8,7 +8,7 @@ Utopia is a website generation framework which provides a robust set of tools to
8
8
 
9
9
  - Designed for both content-based websites and applications. Does not depend on a database.
10
10
  - Supports flexible content localization based on industry recommendations.
11
- - Rack middleware compatible with all major Ruby application servers. Small memory footprint by default.
11
+ - Built directly on `protocol-http` with a small application and middleware interface.
12
12
  - Low latency and high throughput. Capable of 10,000+ requests/second out of the box.
13
13
 
14
14
  ## Usage
@@ -17,7 +17,7 @@ Please see the [project documentation](https://socketry.github.io/utopia/) for m
17
17
 
18
18
  - [Getting Started](https://socketry.github.io/utopia/guides/getting-started/index) - This guide explains how to set up a `utopia` website for local development and deployment.
19
19
 
20
- - [Middleware](https://socketry.github.io/utopia/guides/middleware/index) - This guide gives an overview of the different Rack middleware used by Utopia.
20
+ - [Middleware](https://socketry.github.io/utopia/guides/middleware/index) - This guide gives an overview of the different middleware used by Utopia.
21
21
 
22
22
  - [Server Setup](https://socketry.github.io/utopia/guides/server-setup/index) - This guide explains how to deploy a `utopia` web application.
23
23
 
@@ -31,9 +31,20 @@ 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
- ### Unreleasd
34
+ ### v3.0.0
35
35
 
36
+ The 3.0.x series is considered a development release while the protocol HTTP application and controller interfaces stabilize.
37
+
38
+ - **Breaking** Require Ruby 3.3 or later.
39
+ - **Breaking** Replace the Rack-centric application boundary with <code class="language-ruby">Utopia::Application</code> and <code class="language-ruby">Protocol::HTTP::Middleware</code>. Applications now use `config/application.rb` and `config/serve.rb` instead of `config.ru`.
40
+ - **Breaking** Separate complete protocol responses from semantic controller results. Use `respond!` with a <code class="language-ruby">Protocol::HTTP::Response</code>, or `succeed!` with a value serialized by <code class="language-ruby">Utopia::Controller::Respond</code>.
41
+ - Parse request bodies at the controller layer using `protocol-content`, independently of URL query parameters.
42
+ - Resolve localization through immutable request preferences, avoiding repeated internal controller requests.
43
+ - Split redirection handling into focused middleware for rewrites, moved resources, directory indexes, and error documents.
36
44
  - **Security** Fix handling of redirects that start with `//` to prevent open redirect vulnerabilities.
45
+ - Normalize request URLs using `Protocol::URL` and use structured paths throughout the middleware stack.
46
+ - Resolve static files through encoded URL paths, with improved `HEAD`, range, and validator handling.
47
+ - Use `protocol-media` and `protocol-http` for response and language negotiation, removing the `http-accept` dependency.
37
48
 
38
49
  ### v2.31.0
39
50
 
@@ -57,10 +68,8 @@ Please see the [project releases](https://socketry.github.io/utopia/releases/ind
57
68
  - [Utopia::Gallery](https://github.com/ioquatix/utopia-gallery) — A fast photo gallery based on [libvips](https://github.com/jcupitt/libvips).
58
69
  - [Utopia::Project](https://github.com/socketry/utopia-project) — A Ruby project documentation tool.
59
70
  - [Utopia::Analytics](https://github.com/ioquatix/utopia-analytics) — Simple integration with Google Analytics.
60
- - [HTTP::Accept](https://github.com/ioquatix/http-accept) — RFC compliant header parser.
61
71
  - [Samovar](https://github.com/ioquatix/samovar) — Command line parser used by Utopia.
62
72
  - [Mapping](https://github.com/ioquatix/mapping) — Provide structured conversions for web interfaces.
63
- - [Rack::Test::Body](https://github.com/ioquatix/rack-test-body) — Provide convenient helpers for testing web interfaces.
64
73
 
65
74
  ### Examples
66
75
 
@@ -78,6 +87,22 @@ We welcome contributions to this project.
78
87
  4. Push to the branch (`git push origin my-new-feature`).
79
88
  5. Create new Pull Request.
80
89
 
90
+ ### Running Tests
91
+
92
+ To run the test suite:
93
+
94
+ ``` shell
95
+ bundle exec sus
96
+ ```
97
+
98
+ ### Making Releases
99
+
100
+ To make a new release:
101
+
102
+ ``` shell
103
+ bundle exec bake gem:release:patch # or minor or major
104
+ ```
105
+
81
106
  ### Developer Certificate of Origin
82
107
 
83
108
  In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
data/releases.md CHANGED
@@ -1,8 +1,19 @@
1
1
  # Releases
2
2
 
3
- ## Unreleasd
3
+ ## v3.0.0
4
4
 
5
+ The 3.0.x series is considered a development release while the protocol HTTP application and controller interfaces stabilize.
6
+
7
+ - **Breaking** Require Ruby 3.3 or later.
8
+ - **Breaking** Replace the Rack-centric application boundary with {ruby Utopia::Application} and {ruby Protocol::HTTP::Middleware}. Applications now use `config/application.rb` and `config/serve.rb` instead of `config.ru`.
9
+ - **Breaking** Separate complete protocol responses from semantic controller results. Use `respond!` with a {ruby Protocol::HTTP::Response}, or `succeed!` with a value serialized by {ruby Utopia::Controller::Respond}.
10
+ - Parse request bodies at the controller layer using `protocol-content`, independently of URL query parameters.
11
+ - Resolve localization through immutable request preferences, avoiding repeated internal controller requests.
12
+ - Split redirection handling into focused middleware for rewrites, moved resources, directory indexes, and error documents.
5
13
  - **Security** Fix handling of redirects that start with `//` to prevent open redirect vulnerabilities.
14
+ - Normalize request URLs using `Protocol::URL` and use structured paths throughout the middleware stack.
15
+ - Resolve static files through encoded URL paths, with improved `HEAD`, range, and validator handling.
16
+ - Use `protocol-media` and `protocol-http` for response and language negotiation, removing the `http-accept` dependency.
6
17
 
7
18
  ## v2.31.0
8
19
 
data/setup/site/bake.rb CHANGED
@@ -9,7 +9,7 @@ end
9
9
 
10
10
  # Restart the application server.
11
11
  def restart
12
- call "falcon:supervisor:restart"
12
+ puts "Restart the Falcon service using your process manager."
13
13
  end
14
14
 
15
15
  # Start the development server.
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "environment"
4
+
5
+ require "utopia/application"
6
+ require "utopia/controller"
7
+ require "utopia/content"
8
+ require "utopia/exceptions"
9
+ require "utopia/localization"
10
+ require "utopia/redirection"
11
+ require "utopia/session"
12
+ require "utopia/static"
13
+
14
+ Application = Utopia::Application.build do
15
+ if UTOPIA.production?
16
+ # Handle exceptions in production with an error page and send an email notification:
17
+ use Utopia::Exceptions::Handler
18
+ use Utopia::Exceptions::Mailer
19
+ end
20
+
21
+ use Utopia::Localization,
22
+ default_locale: "en",
23
+ locales: ["en", "de", "ja", "zh"]
24
+
25
+ # Serve static files from "public" directory:
26
+ use Utopia::Static, root: "public"
27
+
28
+ use Utopia::Redirection::Rewrite, {
29
+ "/" => "/welcome/index"
30
+ }
31
+
32
+ use Utopia::Redirection::DirectoryIndex
33
+
34
+ # Handle error documents after client redirects so internal requests bypass them:
35
+ use Utopia::Redirection::Errors, {
36
+ 404 => "/errors/file-not-found"
37
+ }
38
+
39
+ use Utopia::Session,
40
+ expires_after: 3600 * 24,
41
+ secret: UTOPIA.secret_for(:session),
42
+ secure: true
43
+
44
+ use Utopia::Controller
45
+
46
+ # Serve static files from "pages" directory:
47
+ use Utopia::Static
48
+
49
+ # Serve dynamic content:
50
+ use Utopia::Content
51
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "utopia/application"
7
+
8
+ run Utopia::Application.load(File.expand_path("application.rb", __dir__))
data/setup/site/falcon.rb CHANGED
@@ -2,11 +2,24 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  # Released under the MIT License.
5
- # Copyright, 2019-2022, by Samuel Williams.
5
+ # Copyright, 2019-2026, by Samuel Williams.
6
6
 
7
- load :rack, :lets_encrypt_tls, :supervisor
7
+ require "async/service/supervisor"
8
+ require "falcon/environment/application"
9
+ require "falcon/environment/lets_encrypt_tls"
10
+ require "utopia/application"
8
11
 
9
12
  hostname = File.basename(__dir__)
10
- rack hostname, :lets_encrypt_tls
11
13
 
12
- supervisor
14
+ service hostname do
15
+ include Falcon::Environment::Application
16
+ include Falcon::Environment::LetsEncryptTLS
17
+
18
+ def middleware
19
+ Utopia::Application.load
20
+ end
21
+ end
22
+
23
+ service "supervisor" do
24
+ include Async::Service::Supervisor::Environment
25
+ end
@@ -3,24 +3,41 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2016-2025, by Samuel Williams.
5
5
 
6
- require "rack/test"
6
+ require "protocol/http/request"
7
7
  require "sus/fixtures/async/http"
8
- require "protocol/rack"
8
+ require "utopia/application"
9
+ require "uri"
9
10
 
10
11
  AWebsite = Sus::Shared("a website") do
11
- include Rack::Test::Methods
12
+ let(:application_path) {File.expand_path("../config/application.rb", __dir__)}
13
+ let(:application_directory) {File.dirname(application_path)}
12
14
 
13
- let(:rackup_path) {File.expand_path("../config.ru", __dir__)}
14
- let(:rackup_directory) {File.dirname(rackup_path)}
15
+ let(:app) {Utopia::Application.load(application_path)}
15
16
 
16
- let(:app) {Rack::Builder.parse_file(rackup_path)}
17
+ def get(path)
18
+ @last_request = Protocol::HTTP::Request["GET", path]
19
+ @last_response = app.call(@last_request)
20
+ end
21
+
22
+ attr :last_request
23
+ attr :last_response
24
+
25
+ def follow_redirect!
26
+ location = last_response.headers["location"]
27
+ raise "Cannot follow redirect without a location header!" unless location
28
+
29
+ current = URI.join("http://localhost", last_request.path)
30
+ target = URI.join(current, location.to_s)
31
+
32
+ get(target.request_uri)
33
+ end
17
34
  end
18
35
 
19
36
  AValidPage = Sus::Shared("a valid page") do |path|
20
37
  it "can access #{path}" do
21
38
  get path
22
39
 
23
- while last_response.redirect?
40
+ while last_response.redirection?
24
41
  follow_redirect!
25
42
  end
26
43
 
@@ -31,9 +48,8 @@ end
31
48
  AServer = Sus::Shared("a server") do
32
49
  include Sus::Fixtures::Async::HTTP::ServerContext
33
50
 
34
- let(:rackup_path) {File.expand_path("../config.ru", __dir__)}
35
- let(:rackup_directory) {File.dirname(rackup_path)}
51
+ let(:application_path) {File.expand_path("../config/application.rb", __dir__)}
52
+ let(:application_directory) {File.dirname(application_path)}
36
53
 
37
- let(:rack_app) {Rack::Builder.parse_file(rackup_path)}
38
- let(:app) {Protocol::Rack::Adapter.new(rack_app)}
54
+ let(:app) {Utopia::Application.load(application_path)}
39
55
  end
data/setup/site/gems.rb CHANGED
@@ -17,8 +17,6 @@ gem "net-smtp"
17
17
 
18
18
  group :development do
19
19
  gem "bake-test"
20
- gem "rack-test"
21
- gem "guard-falcon", require: false
22
20
 
23
21
  gem "sus"
24
22
  gem "sus-fixtures-async-http"
@@ -29,5 +27,5 @@ group :development do
29
27
  end
30
28
 
31
29
  group :production do
32
- gem "falcon"
30
+ gem "falcon", "~> 0.57"
33
31
  end
@@ -1 +1 @@
1
- You can add additional code for your application in this directory, and require it directly from the config.ru.
1
+ You can add additional code for your application in this directory, and require it directly from config/application.rb.
@@ -11,13 +11,13 @@
11
11
  <div>
12
12
  <i class="fa fa-cubes"></i>
13
13
  <h2>Modular code and structure</h2>
14
- <p>Utopia provides independently useful <a href="https://github.com/rack/rack">Rack</a> middleware and has been designed with simplicity in mind. Several fully-featured webapps and a ton of commercial websites have guided the development of the Utopia stack. It is capable of handling a diverse range of requirements.</p>
14
+ <p>Utopia provides independently useful HTTP middleware and has been designed with simplicity in mind. Several fully-featured webapps and a ton of commercial websites have guided the development of the Utopia stack. It is capable of handling a diverse range of requirements.</p>
15
15
  </div>
16
16
 
17
17
  <div>
18
18
  <i class="fa fa-code"></i>
19
19
  <h2>Well tested and maintained</h2>
20
- <p>Utopia comprises a <a href="https://github.com/ioquatix/utopia">core gem</a> and several supporting libraries, the main ones being <a href="https://github.com/ioquatix/trenni">trenni</a> for templates and parsing, and <a href="https://github.com/ioquatix/http-accept">http-accept</a> for HTTP header processing. Together, these gems have over 90% test coverage.</p>
20
+ <p>Utopia comprises a <a href="https://github.com/ioquatix/utopia">core gem</a> and several supporting libraries, including <a href="https://github.com/socketry/xrb">XRB</a> for templates and markup parsing and <a href="https://github.com/socketry/protocol-http">Protocol HTTP</a> for HTTP protocol handling.</p>
21
21
  </div>
22
22
 
23
23
  <div>
@@ -32,4 +32,4 @@
32
32
  <p>Utopia supports the <code>Accept-Language</code> header and transparently selects the correct view to render. Build multi-lingual websites and webapps easily: translate content incrementally as required, or not at all.</p>
33
33
  </div>
34
34
  </section>
35
- </content:page>
35
+ </content:page>
data/setup/site/readme.md CHANGED
@@ -9,8 +9,5 @@ To start the development server, simply execute
9
9
  > bake
10
10
  Generating transient session key for development...
11
11
  20:57:36 - INFO - Starting Falcon HTTP server on localhost:9292
12
- 20:57:36 - INFO - Guard::RSpec is running
13
- 20:57:36 - INFO - Guard is now watching at '...'
14
- [1] guard(main)>
15
12
 
16
13
  Then browse https://localhost:9292 (or as specified) to see your new site.