From 51d2ae733a340ca18eb5c1ec4df4e6a2a416eedd Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 10 Aug 2026 13:26:02 +1200 Subject: [PATCH 1/5] Unify redirection middleware. Assisted-By: devx/b3414b50-d642-461b-95a8-8f773c4d087b --- context/getting-started.md | 5 +- context/middleware.md | 24 +- guides/getting-started/readme.md | 5 +- guides/middleware/readme.md | 24 +- lib/utopia/redirection.rb | 251 ++---------------- lib/utopia/redirection/builder.rb | 123 +++++++++ lib/utopia/redirection/middleware.rb | 96 +++++++ lib/utopia/redirection/request_failure.rb | 26 ++ lib/utopia/redirection/rule.rb | 16 ++ releases.md | 1 + setup/site/config/application.rb | 14 +- .../utopia/.performance/config/application.rb | 14 +- test/utopia/application_middleware.rb | 8 +- test/utopia/controller/respond.rb | 4 +- test/utopia/redirection.rb | 39 ++- 15 files changed, 360 insertions(+), 290 deletions(-) create mode 100644 lib/utopia/redirection/builder.rb create mode 100644 lib/utopia/redirection/middleware.rb create mode 100644 lib/utopia/redirection/request_failure.rb create mode 100644 lib/utopia/redirection/rule.rb diff --git a/context/getting-started.md b/context/getting-started.md index 4d0d2734..31f97431 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -35,8 +35,9 @@ Utopia includes a redirection middleware to redirect all root-level requests to # in config/application.rb Application = Utopia::Application.build do - use Utopia::Redirection::Rewrite, - "/" => "/welcome/index" + use Utopia::Redirection do |redirects| + redirects.rewrite "/" => "/welcome/index" + end end ``` diff --git a/context/middleware.md b/context/middleware.md index 018bd83c..0c66989a 100644 --- a/context/middleware.md +++ b/context/middleware.md @@ -21,17 +21,19 @@ use Utopia::Static, The {ruby Utopia::Redirection} middleware is used for redirecting requests based on patterns and status codes. ~~~ ruby -# String (fast hash lookup) rewriting: -use Utopia::Redirection::Rewrite, - '/' => '/welcome/index' - -# Redirect directories (e.g. /) to an index file (e.g. /index): -use Utopia::Redirection::DirectoryIndex, - index: 'index.html' - -# Redirect (error) status codes to actual pages: -use Utopia::Redirection::Errors, - 404 => '/errors/file-not-found' +use Utopia::Redirection do |redirects| + # String (fast hash lookup) rewriting: + redirects.rewrite '/' => '/welcome/index' + + # Redirect directories (e.g. /) to an index file (e.g. /index): + redirects.directory_index 'index.html' + + # Redirect matching path prefixes: + redirects.moved '/old/', '/new/' + + # Redirect error status codes to internal error documents: + redirects.error 404, '/errors/file-not-found' +end ~~~ ## Localization diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 4d0d2734..31f97431 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -35,8 +35,9 @@ Utopia includes a redirection middleware to redirect all root-level requests to # in config/application.rb Application = Utopia::Application.build do - use Utopia::Redirection::Rewrite, - "/" => "/welcome/index" + use Utopia::Redirection do |redirects| + redirects.rewrite "/" => "/welcome/index" + end end ``` diff --git a/guides/middleware/readme.md b/guides/middleware/readme.md index 40152254..6452557c 100644 --- a/guides/middleware/readme.md +++ b/guides/middleware/readme.md @@ -21,17 +21,19 @@ use Utopia::Static, The {ruby Utopia::Redirection} middleware is used for redirecting requests based on patterns and status codes. ~~~ ruby -# String (fast hash lookup) rewriting: -use Utopia::Redirection::Rewrite, - '/' => '/welcome/index' - -# Redirect directories (e.g. /) to an index file (e.g. /index): -use Utopia::Redirection::DirectoryIndex, - index: 'index.html' - -# Redirect (error) status codes to actual pages: -use Utopia::Redirection::Errors, - 404 => '/errors/file-not-found' +use Utopia::Redirection do |redirects| + # String (fast hash lookup) rewriting: + redirects.rewrite '/' => '/welcome/index' + + # Redirect directories (e.g. /) to an index file (e.g. /index): + redirects.directory_index 'index.html' + + # Redirect matching path prefixes: + redirects.moved '/old/', '/new/' + + # Redirect error status codes to internal error documents: + redirects.error 404, '/errors/file-not-found' +end ~~~ ## Localization diff --git a/lib/utopia/redirection.rb b/lib/utopia/redirection.rb index af84d8e7..4dfcf291 100644 --- a/lib/utopia/redirection.rb +++ b/lib/utopia/redirection.rb @@ -3,241 +3,28 @@ # Released under the MIT License. # Copyright, 2009-2026, by Samuel Williams. -require_relative "middleware" -require_relative "request" -require_relative "response" - module Utopia - # A middleware which assists with redirecting from one path to another. + # Redirect requests and replace unhandled error responses with error documents. module Redirection - # An error handler fails to redirect to a valid page. - class RequestFailure < StandardError - # Describe a failed attempt to render an error document. - # @parameter resource_path [Object] The resource path. - # @parameter resource_status [Object] The resource status. - # @parameter error_path [Object] The error path. - # @parameter error_status [Object] The error status. - def initialize(resource_path, resource_status, error_path, error_status) - @resource_path = resource_path - @resource_status = resource_status - - @error_path = error_path - @error_status = error_status - - super "Requested resource #{@resource_path} resulted in a #{@resource_status} error. Requested error handler #{@error_path} resulted in a #{@error_status} error." - end - end - - # A middleware which performs internal redirects based on error status codes. - class Errors < Protocol::HTTP::Middleware - # @param codes [Hash] The redirection path for a given error code. - def initialize(app, codes = {}) - super(app) - - @codes = codes - end - - # Freeze this object and its internal state. - # @returns [self] This object. - def freeze - return self if frozen? - - @codes.freeze - - super - end - - # Check whether the response status requires error handling. - # @parameter response [Protocol::HTTP::Response] The response. - # @returns [Boolean] Whether the response is an error without handler-provided headers. - def unhandled_error?(response) - response.status >= 400 && response.headers.empty? - end - - # Replace an unhandled error response with its configured error document. - # @parameter request [Utopia::Request] The request. - # @returns [Protocol::HTTP::Response] The original or error-document response. - # @raises [RequestFailure] If the configured error document also fails. - def call(request) - response = Response.wrap(@delegate.call(request)) - - if unhandled_error?(response) && location = @codes[response.status] - resource_status = response.status - - # The original response is replaced by the configured error document: - response.close - - error_request = request.with(method: "GET", path_info: location) - - error_response = Response.wrap(@delegate.call(error_request)) - - if error_response.status >= 400 - error = RequestFailure.new(request.path_info, resource_status, location, error_response.status) - - # The failed error document will not be returned to the server: - error_response.close(error) - - raise error - else - # Feed the error code back with the error document: - error_response.status = resource_status - return error_response - end - else - return response - end - end - end - - # We cache 301 redirects for 24 hours. + # We cache redirects for 24 hours by default. DEFAULT_MAX_AGE = 3600*24 - - # A basic client-side redirect. - class ClientRedirect < Protocol::HTTP::Middleware - # Initialize client-side redirection behavior. - # @parameter app [Interface(:call)] The downstream application. - # @parameter status [Integer] The status. - # @parameter max_age [Integer] The maximum cache age in seconds. - def initialize(app, status: 307, max_age: DEFAULT_MAX_AGE) - super(app) - - @status = status - @max_age = max_age - end - - # Freeze this object and its internal state. - # @returns [self] This object. - def freeze - return self if frozen? - - @status.freeze - @max_age.freeze - - super - end - - attr :status - attr :max_age - - # Build the cache control header value. - # @returns [String] The cache-control value. - def cache_control - # http://jacquesmattheij.com/301-redirects-a-dangerous-one-way-street - "max-age=#{self.max_age}" - end - - # Build headers for a client redirect. - # @parameter location [String] The redirect location. - # @returns [Hash(String, String)] The redirect headers. - def make_headers(location) - { - HTTP::LOCATION => location, - HTTP::CACHE_CONTROL => self.cache_control - } - end - - # Build a redirect response for the given location. - # @parameter location [String] The redirect location. - # @returns [Protocol::HTTP::Response] The redirect response. - def redirect(location) - return Response[self.status, self.make_headers(location), []] - end - - # Resolve a normalized request path to a redirect response. - # @parameter path [String] The normalized request path. - # @returns [Protocol::HTTP::Response | false] The redirect response, or `false` by default. - def [] path - false - end - - # Redirect a normalized request path when it matches, otherwise invoke the application. - # @parameter request [Utopia::Request] The request. - # @returns [Protocol::HTTP::Response] The redirect or downstream response. - def call(request) - # Normalize the path to remove redundant slashes, `.` and `..` segments. - # This prevents protocol-relative redirect URLs (e.g. //evil.com/index) - # from being generated when PATH_INFO contains a double leading slash. - path = Path.create(request.path_info).simplify.to_s - - if redirection = self[path] - return redirection - end - - return @delegate.call(request) - end - end - - # Redirect urls that end with a `/`, e.g. directories. - class DirectoryIndex < ClientRedirect - # Initialize directory-index redirection. - # @parameter app [Interface(:call)] The downstream application. - # @parameter index [Integer] The index. - def initialize(app, index: "index") - @index = index - - super(app) - end - - # Redirect a directory path to its index path. - # @parameter path [String] The normalized request path. - # @returns [Protocol::HTTP::Response | Nil] The redirect response when the path ends with `/`. - def [] path - if path.end_with?("/") - return redirect(path + @index) - end - end - end - - # Rewrite requests that match the given pattern to a single destination. - class Rewrite < ClientRedirect - # Initialize exact-path redirections. - # @parameter app [Interface(:call)] The downstream application. - # @parameter patterns [Hash] The path rewrite patterns. - # @parameter status [Integer] The status. - def initialize(app, patterns, status: 301) - @patterns = patterns - - super(app, status: status) - end - - # Redirect a path found in the rewrite map. - # @parameter path [String] The normalized request path. - # @returns [Protocol::HTTP::Response | Nil] The redirect response when the path is mapped. - def [] path - if location = @patterns[path] - return redirect(location) - end - end - end - - # Rewrite requests that match the given pattern to a new prefix. - class Moved < ClientRedirect - # Initialize prefix redirection behavior. - # @parameter app [Interface(:call)] The downstream application. - # @parameter pattern [Regexp] The path pattern. - # @parameter prefix [String] The prefix. - # @parameter status [Integer] The status. - # @parameter flatten [bool] Whether to flatten the rewritten path. - def initialize(app, pattern, prefix, status: 301, flatten: false) - @pattern = pattern - @prefix = prefix - @flatten = flatten - - super(app, status: status) - end - - # Redirect a matching path to the configured prefix. - # @parameter path [String] The normalized request path. - # @returns [Protocol::HTTP::Response | Nil] The redirect response when the pattern matches. - def [] path - if path.start_with?(@pattern) - if @flatten - return redirect(@prefix) - else - return redirect(path.sub(@pattern, @prefix)) - end - end - end + end +end + +require_relative "redirection/request_failure" +require_relative "redirection/rule" +require_relative "redirection/builder" +require_relative "redirection/middleware" + +module Utopia + module Redirection + # Construct unified redirection middleware. + # @parameter delegate [Protocol::HTTP::Middleware] The downstream middleware. + # @yields {|builder| ...} The redirection configuration. + # @returns [Middleware] The configured middleware. + def self.new(delegate, &block) + builder = Builder.new.build(&block) + return Middleware.new(delegate, builder) end end end diff --git a/lib/utopia/redirection/builder.rb b/lib/utopia/redirection/builder.rb new file mode 100644 index 00000000..ac63ed1e --- /dev/null +++ b/lib/utopia/redirection/builder.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Utopia + module Redirection + # Builds immutable redirection rules for {Middleware}. + class Builder + def initialize + @rules = [] + @errors = {} + end + + # The configured request redirection rules. + # @returns [Array] The rules in declaration order. + attr :rules + + # The configured error document paths indexed by response status. + # @returns [Hash(Integer, String)] The configured error documents. + attr :errors + + # Configure and freeze this builder. + # @yields {|builder| ...} The redirection configuration. + # @returns [self] This builder. + def build(&block) + if block + if block.arity.zero? + instance_exec(&block) + else + block.call(self) + end + end + + freeze + + return self + end + + # Freeze this builder and its configured rules. + # @returns [self] This builder. + def freeze + return self if frozen? + + @rules.freeze + @errors.freeze + + return super + end + + # Add an exact-path rewrite map. + # @parameter patterns [Hash(String, String)] Exact paths and their destinations. + # @parameter status [Integer] The redirect response status. + # @parameter max_age [Integer] The redirect cache lifetime in seconds. + # @returns [self] This builder. + def rewrite(patterns = nil, status: 301, max_age: DEFAULT_MAX_AGE, **keyword_patterns) + # Ruby interprets an unbraced path map as keyword arguments: + if patterns.nil? + patterns = keyword_patterns + end + + patterns = patterns.dup.freeze + + add(status, max_age) do |path| + patterns[path] + end + + return self + end + + # Redirect paths ending in a slash to an index path. + # @parameter index [String] The index path component. + # @parameter status [Integer] The redirect response status. + # @parameter max_age [Integer] The redirect cache lifetime in seconds. + # @returns [self] This builder. + def directory_index(index = "index", status: 307, max_age: DEFAULT_MAX_AGE) + add(status, max_age) do |path| + if path.end_with?("/") + path + index + end + end + + return self + end + + # Redirect paths beginning with one prefix to another prefix. + # @parameter pattern [String] The source path prefix. + # @parameter prefix [String] The destination path prefix. + # @parameter status [Integer] The redirect response status. + # @parameter flatten [Boolean] Whether to discard the matched path suffix. + # @parameter max_age [Integer] The redirect cache lifetime in seconds. + # @returns [self] This builder. + def moved(pattern, prefix, status: 301, flatten: false, max_age: DEFAULT_MAX_AGE) + add(status, max_age) do |path| + if path.start_with?(pattern) + if flatten + prefix + else + path.sub(pattern, prefix) + end + end + end + + return self + end + + # Replace an unhandled response status with an internal error document. + # @parameter status [Integer] The response status to handle. + # @parameter path [String] The internal error document path. + # @returns [self] This builder. + def error(status, path) + @errors[status] = path + return self + end + + private + + def add(status, max_age, &resolver) + @rules << Rule.new(status, max_age, resolver) + end + end + end +end diff --git a/lib/utopia/redirection/middleware.rb b/lib/utopia/redirection/middleware.rb new file mode 100644 index 00000000..a1b2c9b0 --- /dev/null +++ b/lib/utopia/redirection/middleware.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "../middleware" +require_relative "../request" +require_relative "../response" + +module Utopia + module Redirection + # Applies configured request redirections and error documents. + class Middleware < Protocol::HTTP::Middleware + # Initialize redirection handling. + # @parameter delegate [Protocol::HTTP::Middleware] The downstream middleware. + # @parameter builder [Builder] The configured redirection builder. + def initialize(delegate, builder) + super(delegate) + + @rules = builder.rules + @errors = builder.errors + end + + # Build a redirect response for the given rule and location. + # @parameter rule [Object] The matching redirection rule. + # @parameter location [String] The redirect destination. + # @returns [Protocol::HTTP::Response] The redirect response. + def redirect(rule, location) + headers = { + HTTP::LOCATION => location, + HTTP::CACHE_CONTROL => "max-age=#{rule.max_age}" + } + + return Response[rule.status, headers, []] + end + + # Check whether the response status requires error handling. + # @parameter response [Protocol::HTTP::Response] The response. + # @returns [Boolean] Whether the response is an error without handler-provided headers. + def unhandled_error?(response) + response.status >= 400 && response.headers.empty? + end + + # Replace an unhandled error response with its configured error document. + # @parameter request [Utopia::Request] The request. + # @parameter response [Protocol::HTTP::Response] The unhandled error response. + # @parameter location [String] The configured error document path. + # @returns [Protocol::HTTP::Response] The error-document response. + # @raises [RequestFailure] If the configured error document also fails. + def replace_error(request, response, location) + resource_status = response.status + + # The original response is replaced by the configured error document: + response.close + + error_request = request.with(method: "GET", path_info: location) + error_response = Response.wrap(@delegate.call(error_request)) + + if error_response.status >= 400 + error = RequestFailure.new(request.path_info, resource_status, location, error_response.status) + + # The failed error document will not be returned to the server: + error_response.close(error) + + raise error + end + + # Feed the error code back with the error document: + error_response.status = resource_status + return error_response + end + + # Apply request redirections, invoke the delegate, and handle error responses. + # @parameter request [Utopia::Request] The request. + # @returns [Protocol::HTTP::Response] The resulting response. + def call(request) + # Normalize the path once to remove redundant slashes and dot segments: + path = Path.create(request.path_info).simplify.to_s + + @rules.each do |rule| + if location = rule.call(path) + return redirect(rule, location) + end + end + + response = Response.wrap(@delegate.call(request)) + + if unhandled_error?(response) && location = @errors[response.status] + return replace_error(request, response, location) + end + + return response + end + end + end +end diff --git a/lib/utopia/redirection/request_failure.rb b/lib/utopia/redirection/request_failure.rb new file mode 100644 index 00000000..40d95286 --- /dev/null +++ b/lib/utopia/redirection/request_failure.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Utopia + module Redirection + # An error handler failed to produce a valid response. + class RequestFailure < StandardError + # Describe a failed attempt to render an error document. + # @parameter resource_path [Object] The resource path. + # @parameter resource_status [Object] The resource status. + # @parameter error_path [Object] The error path. + # @parameter error_status [Object] The error status. + def initialize(resource_path, resource_status, error_path, error_status) + @resource_path = resource_path + @resource_status = resource_status + + @error_path = error_path + @error_status = error_status + + super "Requested resource #{@resource_path} resulted in a #{@resource_status} error. Requested error handler #{@error_path} resulted in a #{@error_status} error." + end + end + end +end diff --git a/lib/utopia/redirection/rule.rb b/lib/utopia/redirection/rule.rb new file mode 100644 index 00000000..69423c26 --- /dev/null +++ b/lib/utopia/redirection/rule.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Utopia + module Redirection + Rule = Data.define(:status, :max_age, :resolver) do + def call(path) + resolver.call(path) + end + end + + private_constant :Rule + end +end diff --git a/releases.md b/releases.md index f1f0e85b..a437381b 100644 --- a/releases.md +++ b/releases.md @@ -4,6 +4,7 @@ - **Security** Fix handling of redirects that start with `//` to prevent open redirect vulnerabilities. - Use `protocol-media` and `protocol-http` for response and language negotiation, removing the `http-accept` dependency. + - Combine request redirections and error documents into one configurable redirection middleware. ## v2.31.0 diff --git a/setup/site/config/application.rb b/setup/site/config/application.rb index a520688c..c1c8ae15 100644 --- a/setup/site/config/application.rb +++ b/setup/site/config/application.rb @@ -25,15 +25,11 @@ # Serve static files from "public" directory: use Utopia::Static, root: "public" - use Utopia::Redirection::Rewrite, { - "/" => "/welcome/index" - } - - use Utopia::Redirection::DirectoryIndex - - use Utopia::Redirection::Errors, { - 404 => "/errors/file-not-found" - } + use Utopia::Redirection do |redirects| + redirects.rewrite "/" => "/welcome/index" + redirects.directory_index + redirects.error 404, "/errors/file-not-found" + end use Utopia::Session, expires_after: 3600 * 24, diff --git a/test/utopia/.performance/config/application.rb b/test/utopia/.performance/config/application.rb index 46c0f513..6d4a9a0f 100644 --- a/test/utopia/.performance/config/application.rb +++ b/test/utopia/.performance/config/application.rb @@ -11,15 +11,11 @@ ROOT = File.expand_path("../pages", __dir__) Application = Utopia::Application.build do - use Utopia::Redirection::Rewrite, { - "/" => "/welcome/index" - } - - use Utopia::Redirection::DirectoryIndex - - use Utopia::Redirection::Errors, { - 404 => "/errors/file-not-found" - } + use Utopia::Redirection do |redirects| + redirects.rewrite "/" => "/welcome/index" + redirects.directory_index + redirects.error 404, "/errors/file-not-found" + end use Utopia::Controller, root: ROOT use Utopia::Static, root: ROOT diff --git a/test/utopia/application_middleware.rb b/test/utopia/application_middleware.rb index 6a4745c0..dedb4edb 100644 --- a/test/utopia/application_middleware.rb +++ b/test/utopia/application_middleware.rb @@ -22,7 +22,9 @@ def request(path, headers: nil) seen_request = nil application = Utopia::Application.build do - use Utopia::Redirection::Rewrite, {"/old" => "/new"} + use Utopia::Redirection do |redirects| + redirects.rewrite "/old" => "/new" + end run Protocol::HTTP::Middleware.for{|request| seen_request = request @@ -71,7 +73,9 @@ def request(path, headers: nil) end utopia_application = Utopia::Application.build do - use Utopia::Redirection::Rewrite, {"/old" => "/new"} + use Utopia::Redirection do |redirects| + redirects.rewrite "/old" => "/new" + end use Utopia::Static run application end diff --git a/test/utopia/controller/respond.rb b/test/utopia/controller/respond.rb index e444f937..78027389 100644 --- a/test/utopia/controller/respond.rb +++ b/test/utopia/controller/respond.rb @@ -154,7 +154,9 @@ def mock_request(path, headers = {}) root = File.expand_path(".respond", __dir__) Utopia::Application.build(Protocol::HTTP::Middleware.for{|request| Utopia::Response[404, {}, []]}) do - use Utopia::Redirection::Errors, 404 => "/fail" + use Utopia::Redirection do |redirects| + redirects.error 404, "/fail" + end use Utopia::Controller, root: root use Utopia::Content, root: root end diff --git a/test/utopia/redirection.rb b/test/utopia/redirection.rb index 1076e9f3..a2e20a17 100644 --- a/test/utopia/redirection.rb +++ b/test/utopia/redirection.rb @@ -32,15 +32,15 @@ def tracked_body(name, events) Utopia::Response[404, {}, []] end }) do - use Utopia::Redirection::Rewrite, {"/" => "/welcome/index"} - use Utopia::Redirection::DirectoryIndex - use Utopia::Redirection::Errors, { - 404 => "/error", - 418 => "/teapot" - } - use Utopia::Redirection::Moved, "/a", "/b" - use Utopia::Redirection::Moved, "/hierarchy/", "/hierarchy", flatten: true - use Utopia::Redirection::Moved, "/weird", "/status", status: 333 + use Utopia::Redirection do |redirects| + redirects.rewrite "/" => "/welcome/index" + redirects.directory_index + redirects.error 404, "/error" + redirects.error 418, "/teapot" + redirects.moved "/a", "/b" + redirects.moved "/hierarchy/", "/hierarchy", flatten: true + redirects.moved "/weird", "/status", status: 333 + end end end @@ -93,7 +93,9 @@ def tracked_body(name, events) Utopia::Response[404, {}, tracked_body(:original, events)] end end) do - use Utopia::Redirection::Errors, 404 => "/error" + use Utopia::Redirection do + error 404, "/error" + end end response = application.call(Protocol::HTTP::Request["GET", "/missing"]) @@ -116,7 +118,9 @@ def tracked_body(name, events) Utopia::Response[404, {}, tracked_body(:original, events)] end end) do - use Utopia::Redirection::Errors, 404 => "/error" + use Utopia::Redirection do |redirects| + redirects.error 404, "/error" + end end expect do @@ -142,4 +146,17 @@ def tracked_body(name, events) expect(last_response.status).to be == 333 expect(last_response.headers["location"]).to be == "/status" end + + it "applies request rules in declaration order" do + application = Utopia::Application.build do + use Utopia::Redirection do |redirects| + redirects.rewrite "/files/" => "/exact" + redirects.directory_index "index" + end + end + + response = application.call(Protocol::HTTP::Request["GET", "/files/"]) + + expect(response.headers["location"]).to be == "/exact" + end end From f35e4e5489120c9ab19188d02c859eed47c13a76 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 10 Aug 2026 13:39:06 +1200 Subject: [PATCH 2/5] Move the redirect cache default into the builder. Assisted-By: devx/b3414b50-d642-461b-95a8-8f773c4d087b --- lib/utopia/redirection.rb | 2 -- lib/utopia/redirection/builder.rb | 9 ++++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/utopia/redirection.rb b/lib/utopia/redirection.rb index 4dfcf291..5d216e18 100644 --- a/lib/utopia/redirection.rb +++ b/lib/utopia/redirection.rb @@ -6,8 +6,6 @@ module Utopia # Redirect requests and replace unhandled error responses with error documents. module Redirection - # We cache redirects for 24 hours by default. - DEFAULT_MAX_AGE = 3600*24 end end diff --git a/lib/utopia/redirection/builder.rb b/lib/utopia/redirection/builder.rb index ac63ed1e..47a8317b 100644 --- a/lib/utopia/redirection/builder.rb +++ b/lib/utopia/redirection/builder.rb @@ -7,6 +7,9 @@ module Utopia module Redirection # Builds immutable redirection rules for {Middleware}. class Builder + # The default redirect cache lifetime is 24 hours. + MAX_AGE = 3600*24 + def initialize @rules = [] @errors = {} @@ -53,7 +56,7 @@ def freeze # @parameter status [Integer] The redirect response status. # @parameter max_age [Integer] The redirect cache lifetime in seconds. # @returns [self] This builder. - def rewrite(patterns = nil, status: 301, max_age: DEFAULT_MAX_AGE, **keyword_patterns) + def rewrite(patterns = nil, status: 301, max_age: MAX_AGE, **keyword_patterns) # Ruby interprets an unbraced path map as keyword arguments: if patterns.nil? patterns = keyword_patterns @@ -73,7 +76,7 @@ def rewrite(patterns = nil, status: 301, max_age: DEFAULT_MAX_AGE, **keyword_pat # @parameter status [Integer] The redirect response status. # @parameter max_age [Integer] The redirect cache lifetime in seconds. # @returns [self] This builder. - def directory_index(index = "index", status: 307, max_age: DEFAULT_MAX_AGE) + def directory_index(index = "index", status: 307, max_age: MAX_AGE) add(status, max_age) do |path| if path.end_with?("/") path + index @@ -90,7 +93,7 @@ def directory_index(index = "index", status: 307, max_age: DEFAULT_MAX_AGE) # @parameter flatten [Boolean] Whether to discard the matched path suffix. # @parameter max_age [Integer] The redirect cache lifetime in seconds. # @returns [self] This builder. - def moved(pattern, prefix, status: 301, flatten: false, max_age: DEFAULT_MAX_AGE) + def moved(pattern, prefix, status: 301, flatten: false, max_age: MAX_AGE) add(status, max_age) do |path| if path.start_with?(pattern) if flatten From bc1e5b48d8a494e8ef6ba384e0795e28a49a3858 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 10 Aug 2026 13:40:05 +1200 Subject: [PATCH 3/5] Remove the empty redirection namespace declaration. Assisted-By: devx/b3414b50-d642-461b-95a8-8f773c4d087b --- lib/utopia/redirection.rb | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/utopia/redirection.rb b/lib/utopia/redirection.rb index 5d216e18..25e65a4e 100644 --- a/lib/utopia/redirection.rb +++ b/lib/utopia/redirection.rb @@ -3,18 +3,13 @@ # Released under the MIT License. # Copyright, 2009-2026, by Samuel Williams. -module Utopia - # Redirect requests and replace unhandled error responses with error documents. - module Redirection - end -end - require_relative "redirection/request_failure" require_relative "redirection/rule" require_relative "redirection/builder" require_relative "redirection/middleware" module Utopia + # Redirect requests and replace unhandled error responses with error documents. module Redirection # Construct unified redirection middleware. # @parameter delegate [Protocol::HTTP::Middleware] The downstream middleware. From 8dbae728b1170284f058c6ac22cb069b2e3dcc59 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 10 Aug 2026 13:43:15 +1200 Subject: [PATCH 4/5] Document the redirection builder initialization. Assisted-By: devx/b3414b50-d642-461b-95a8-8f773c4d087b --- lib/utopia/redirection/builder.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/utopia/redirection/builder.rb b/lib/utopia/redirection/builder.rb index 47a8317b..404db751 100644 --- a/lib/utopia/redirection/builder.rb +++ b/lib/utopia/redirection/builder.rb @@ -10,6 +10,7 @@ class Builder # The default redirect cache lifetime is 24 hours. MAX_AGE = 3600*24 + # Initialize an empty redirection configuration. def initialize @rules = [] @errors = {} From 0a7ed5e3807b2ca41abc98601d70f4f6c7efa495 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 10 Aug 2026 14:20:41 +1200 Subject: [PATCH 5/5] Separate internal error redirections. Assisted-By: devx/b3414b50-d642-461b-95a8-8f773c4d087b --- context/middleware.md | 12 ++- guides/middleware/readme.md | 12 ++- lib/utopia/redirection.rb | 1 + lib/utopia/redirection/builder.rb | 15 ---- lib/utopia/redirection/errors.rb | 84 +++++++++++++++++++ lib/utopia/redirection/middleware.rb | 49 +---------- releases.md | 2 +- setup/site/config/application.rb | 2 +- .../utopia/.performance/config/application.rb | 2 +- test/utopia/controller/respond.rb | 4 +- test/utopia/redirection.rb | 32 +++++-- 11 files changed, 132 insertions(+), 83 deletions(-) create mode 100644 lib/utopia/redirection/errors.rb diff --git a/context/middleware.md b/context/middleware.md index 0c66989a..87aab06c 100644 --- a/context/middleware.md +++ b/context/middleware.md @@ -18,7 +18,7 @@ use Utopia::Static, ## Redirection -The {ruby Utopia::Redirection} middleware is used for redirecting requests based on patterns and status codes. +The {ruby Utopia::Redirection} middleware is used for redirecting requests based on paths. ~~~ ruby use Utopia::Redirection do |redirects| @@ -30,12 +30,16 @@ use Utopia::Redirection do |redirects| # Redirect matching path prefixes: redirects.moved '/old/', '/new/' - - # Redirect error status codes to internal error documents: - redirects.error 404, '/errors/file-not-found' end ~~~ +The {ruby Utopia::Redirection::Errors} middleware maps unhandled error responses to internal error documents. It retains the original response status and does not issue a client-visible redirect: + +~~~ ruby +use Utopia::Redirection::Errors, + 404 => '/errors/file-not-found' +~~~ + ## Localization The {ruby Utopia::Localization} middleware computes immutable localization preferences from the request path, host, and `accept-language` header. Localization-aware resource middleware, including {ruby Utopia::Static} and {ruby Utopia::Content}, resolves those preferences without invoking controllers more than once. Place the localization middleware before those resources in the middleware stack. diff --git a/guides/middleware/readme.md b/guides/middleware/readme.md index 6452557c..9950bade 100644 --- a/guides/middleware/readme.md +++ b/guides/middleware/readme.md @@ -18,7 +18,7 @@ use Utopia::Static, ## Redirection -The {ruby Utopia::Redirection} middleware is used for redirecting requests based on patterns and status codes. +The {ruby Utopia::Redirection} middleware is used for redirecting requests based on paths. ~~~ ruby use Utopia::Redirection do |redirects| @@ -30,12 +30,16 @@ use Utopia::Redirection do |redirects| # Redirect matching path prefixes: redirects.moved '/old/', '/new/' - - # Redirect error status codes to internal error documents: - redirects.error 404, '/errors/file-not-found' end ~~~ +The {ruby Utopia::Redirection::Errors} middleware maps unhandled error responses to internal error documents. It retains the original response status and does not issue a client-visible redirect: + +~~~ ruby +use Utopia::Redirection::Errors, + 404 => '/errors/file-not-found' +~~~ + ## Localization The {ruby Utopia::Localization} middleware computes immutable localization preferences from the request path, host, and `accept-language` header. Localization-aware resource middleware, including {ruby Utopia::Static} and {ruby Utopia::Content}, resolves those preferences without invoking controllers more than once. Place the localization middleware before those resources in the middleware stack. diff --git a/lib/utopia/redirection.rb b/lib/utopia/redirection.rb index 25e65a4e..f1e8fb14 100644 --- a/lib/utopia/redirection.rb +++ b/lib/utopia/redirection.rb @@ -4,6 +4,7 @@ # Copyright, 2009-2026, by Samuel Williams. require_relative "redirection/request_failure" +require_relative "redirection/errors" require_relative "redirection/rule" require_relative "redirection/builder" require_relative "redirection/middleware" diff --git a/lib/utopia/redirection/builder.rb b/lib/utopia/redirection/builder.rb index 404db751..4d9ca4b5 100644 --- a/lib/utopia/redirection/builder.rb +++ b/lib/utopia/redirection/builder.rb @@ -13,17 +13,12 @@ class Builder # Initialize an empty redirection configuration. def initialize @rules = [] - @errors = {} end # The configured request redirection rules. # @returns [Array] The rules in declaration order. attr :rules - # The configured error document paths indexed by response status. - # @returns [Hash(Integer, String)] The configured error documents. - attr :errors - # Configure and freeze this builder. # @yields {|builder| ...} The redirection configuration. # @returns [self] This builder. @@ -47,7 +42,6 @@ def freeze return self if frozen? @rules.freeze - @errors.freeze return super end @@ -108,15 +102,6 @@ def moved(pattern, prefix, status: 301, flatten: false, max_age: MAX_AGE) return self end - # Replace an unhandled response status with an internal error document. - # @parameter status [Integer] The response status to handle. - # @parameter path [String] The internal error document path. - # @returns [self] This builder. - def error(status, path) - @errors[status] = path - return self - end - private def add(status, max_age, &resolver) diff --git a/lib/utopia/redirection/errors.rb b/lib/utopia/redirection/errors.rb new file mode 100644 index 00000000..e4d35f0a --- /dev/null +++ b/lib/utopia/redirection/errors.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2009-2026, by Samuel Williams. + +require_relative "../middleware" +require_relative "../request" +require_relative "../response" +require_relative "request_failure" + +module Utopia + module Redirection + # Performs internal redirections for unhandled error responses. + class Errors < Protocol::HTTP::Middleware + # Initialize internal error redirections. + # @parameter delegate [Protocol::HTTP::Middleware] The downstream middleware. + # @parameter codes [Hash(Integer, String)] The internal path for each error status. + def initialize(delegate, codes = {}) + super(delegate) + + @codes = codes + end + + # Freeze this object and its internal state. + # @returns [self] This object. + def freeze + return self if frozen? + + @codes.freeze + + return super + end + + # Check whether the response status requires error handling. + # @parameter response [Protocol::HTTP::Response] The response. + # @returns [Boolean] Whether the response is an error without handler-provided headers. + def unhandled_error?(response) + response.status >= 400 && response.headers.empty? + end + + # Replace an unhandled error response with its configured error document. + # @parameter request [Utopia::Request] The request. + # @parameter response [Protocol::HTTP::Response] The unhandled error response. + # @parameter location [String] The configured error document path. + # @returns [Protocol::HTTP::Response] The error-document response. + # @raises [RequestFailure] If the configured error document also fails. + def replace_error(request, response, location) + resource_status = response.status + + # The original response is replaced by the configured error document: + response.close + + error_request = request.with(method: "GET", path_info: location) + error_response = Response.wrap(@delegate.call(error_request)) + + if error_response.status >= 400 + error = RequestFailure.new(request.path_info, resource_status, location, error_response.status) + + # The failed error document will not be returned to the server: + error_response.close(error) + + raise error + end + + # Feed the error code back with the error document: + error_response.status = resource_status + return error_response + end + + # Replace configured unhandled responses through an internal request. + # @parameter request [Utopia::Request] The request. + # @returns [Protocol::HTTP::Response] The original or error-document response. + def call(request) + response = Response.wrap(@delegate.call(request)) + + if unhandled_error?(response) && location = @codes[response.status] + return replace_error(request, response, location) + end + + return response + end + end + end +end diff --git a/lib/utopia/redirection/middleware.rb b/lib/utopia/redirection/middleware.rb index a1b2c9b0..7acc4a09 100644 --- a/lib/utopia/redirection/middleware.rb +++ b/lib/utopia/redirection/middleware.rb @@ -9,7 +9,7 @@ module Utopia module Redirection - # Applies configured request redirections and error documents. + # Applies configured request redirections. class Middleware < Protocol::HTTP::Middleware # Initialize redirection handling. # @parameter delegate [Protocol::HTTP::Middleware] The downstream middleware. @@ -18,7 +18,6 @@ def initialize(delegate, builder) super(delegate) @rules = builder.rules - @errors = builder.errors end # Build a redirect response for the given rule and location. @@ -34,43 +33,7 @@ def redirect(rule, location) return Response[rule.status, headers, []] end - # Check whether the response status requires error handling. - # @parameter response [Protocol::HTTP::Response] The response. - # @returns [Boolean] Whether the response is an error without handler-provided headers. - def unhandled_error?(response) - response.status >= 400 && response.headers.empty? - end - - # Replace an unhandled error response with its configured error document. - # @parameter request [Utopia::Request] The request. - # @parameter response [Protocol::HTTP::Response] The unhandled error response. - # @parameter location [String] The configured error document path. - # @returns [Protocol::HTTP::Response] The error-document response. - # @raises [RequestFailure] If the configured error document also fails. - def replace_error(request, response, location) - resource_status = response.status - - # The original response is replaced by the configured error document: - response.close - - error_request = request.with(method: "GET", path_info: location) - error_response = Response.wrap(@delegate.call(error_request)) - - if error_response.status >= 400 - error = RequestFailure.new(request.path_info, resource_status, location, error_response.status) - - # The failed error document will not be returned to the server: - error_response.close(error) - - raise error - end - - # Feed the error code back with the error document: - error_response.status = resource_status - return error_response - end - - # Apply request redirections, invoke the delegate, and handle error responses. + # Apply request redirections and invoke the delegate when none match. # @parameter request [Utopia::Request] The request. # @returns [Protocol::HTTP::Response] The resulting response. def call(request) @@ -83,13 +46,7 @@ def call(request) end end - response = Response.wrap(@delegate.call(request)) - - if unhandled_error?(response) && location = @errors[response.status] - return replace_error(request, response, location) - end - - return response + return @delegate.call(request) end end end diff --git a/releases.md b/releases.md index a437381b..0e7d5fea 100644 --- a/releases.md +++ b/releases.md @@ -4,7 +4,7 @@ - **Security** Fix handling of redirects that start with `//` to prevent open redirect vulnerabilities. - Use `protocol-media` and `protocol-http` for response and language negotiation, removing the `http-accept` dependency. - - Combine request redirections and error documents into one configurable redirection middleware. + - Combine client-facing request redirections into one configurable middleware. ## v2.31.0 diff --git a/setup/site/config/application.rb b/setup/site/config/application.rb index c1c8ae15..3c3d19a7 100644 --- a/setup/site/config/application.rb +++ b/setup/site/config/application.rb @@ -28,8 +28,8 @@ use Utopia::Redirection do |redirects| redirects.rewrite "/" => "/welcome/index" redirects.directory_index - redirects.error 404, "/errors/file-not-found" end + use Utopia::Redirection::Errors, 404 => "/errors/file-not-found" use Utopia::Session, expires_after: 3600 * 24, diff --git a/test/utopia/.performance/config/application.rb b/test/utopia/.performance/config/application.rb index 6d4a9a0f..994ee623 100644 --- a/test/utopia/.performance/config/application.rb +++ b/test/utopia/.performance/config/application.rb @@ -14,8 +14,8 @@ use Utopia::Redirection do |redirects| redirects.rewrite "/" => "/welcome/index" redirects.directory_index - redirects.error 404, "/errors/file-not-found" end + use Utopia::Redirection::Errors, 404 => "/errors/file-not-found" use Utopia::Controller, root: ROOT use Utopia::Static, root: ROOT diff --git a/test/utopia/controller/respond.rb b/test/utopia/controller/respond.rb index 78027389..e444f937 100644 --- a/test/utopia/controller/respond.rb +++ b/test/utopia/controller/respond.rb @@ -154,9 +154,7 @@ def mock_request(path, headers = {}) root = File.expand_path(".respond", __dir__) Utopia::Application.build(Protocol::HTTP::Middleware.for{|request| Utopia::Response[404, {}, []]}) do - use Utopia::Redirection do |redirects| - redirects.error 404, "/fail" - end + use Utopia::Redirection::Errors, 404 => "/fail" use Utopia::Controller, root: root use Utopia::Content, root: root end diff --git a/test/utopia/redirection.rb b/test/utopia/redirection.rb index a2e20a17..4d0b6eb3 100644 --- a/test/utopia/redirection.rb +++ b/test/utopia/redirection.rb @@ -35,12 +35,11 @@ def tracked_body(name, events) use Utopia::Redirection do |redirects| redirects.rewrite "/" => "/welcome/index" redirects.directory_index - redirects.error 404, "/error" - redirects.error 418, "/teapot" redirects.moved "/a", "/b" redirects.moved "/hierarchy/", "/hierarchy", flatten: true redirects.moved "/weird", "/status", status: 333 end + use Utopia::Redirection::Errors, 404 => "/error", 418 => "/teapot" end end @@ -84,6 +83,27 @@ def tracked_body(name, events) expect(last_response.read).to be == "File not found :(" end + it "bypasses request redirections for internal error documents" do + application = Utopia::Application.build(Protocol::HTTP::Middleware.for do |request| + if request.path_info == "/error" + Utopia::Response.text("Internal error document") + else + Utopia::Response[404, {}, []] + end + end) do + use Utopia::Redirection do |redirects| + redirects.rewrite "/error" => "/redirected" + end + use Utopia::Redirection::Errors, 404 => "/error" + end + + response = application.call(Protocol::HTTP::Request["GET", "/missing"]) + + expect(response.status).to be == 404 + expect(response.headers["location"]).to be == nil + expect(response.read).to be == "Internal error document" + end + it "closes the response replaced by an error document" do events = [] application = Utopia::Application.build(Protocol::HTTP::Middleware.for do |request| @@ -93,9 +113,7 @@ def tracked_body(name, events) Utopia::Response[404, {}, tracked_body(:original, events)] end end) do - use Utopia::Redirection do - error 404, "/error" - end + use Utopia::Redirection::Errors, 404 => "/error" end response = application.call(Protocol::HTTP::Request["GET", "/missing"]) @@ -118,9 +136,7 @@ def tracked_body(name, events) Utopia::Response[404, {}, tracked_body(:original, events)] end end) do - use Utopia::Redirection do |redirects| - redirects.error 404, "/error" - end + use Utopia::Redirection::Errors, 404 => "/error" end expect do