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..87aab06c 100644 --- a/context/middleware.md +++ b/context/middleware.md @@ -18,18 +18,24 @@ 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 -# String (fast hash lookup) rewriting: -use Utopia::Redirection::Rewrite, - '/' => '/welcome/index' +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/' +end +~~~ -# Redirect directories (e.g. /) to an index file (e.g. /index): -use Utopia::Redirection::DirectoryIndex, - index: 'index.html' +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: -# Redirect (error) status codes to actual pages: +~~~ ruby use Utopia::Redirection::Errors, 404 => '/errors/file-not-found' ~~~ 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..9950bade 100644 --- a/guides/middleware/readme.md +++ b/guides/middleware/readme.md @@ -18,18 +18,24 @@ 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 -# String (fast hash lookup) rewriting: -use Utopia::Redirection::Rewrite, - '/' => '/welcome/index' +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/' +end +~~~ -# Redirect directories (e.g. /) to an index file (e.g. /index): -use Utopia::Redirection::DirectoryIndex, - index: 'index.html' +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: -# Redirect (error) status codes to actual pages: +~~~ ruby use Utopia::Redirection::Errors, 404 => '/errors/file-not-found' ~~~ diff --git a/lib/utopia/redirection.rb b/lib/utopia/redirection.rb index af84d8e7..f1e8fb14 100644 --- a/lib/utopia/redirection.rb +++ b/lib/utopia/redirection.rb @@ -3,241 +3,22 @@ # Released under the MIT License. # Copyright, 2009-2026, by Samuel Williams. -require_relative "middleware" -require_relative "request" -require_relative "response" +require_relative "redirection/request_failure" +require_relative "redirection/errors" +require_relative "redirection/rule" +require_relative "redirection/builder" +require_relative "redirection/middleware" 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. - 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 + # 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..4d9ca4b5 --- /dev/null +++ b/lib/utopia/redirection/builder.rb @@ -0,0 +1,112 @@ +# 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 + # The default redirect cache lifetime is 24 hours. + MAX_AGE = 3600*24 + + # Initialize an empty redirection configuration. + def initialize + @rules = [] + end + + # The configured request redirection rules. + # @returns [Array] The rules in declaration order. + attr :rules + + # 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 + + 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: 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: 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: 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 + + private + + def add(status, max_age, &resolver) + @rules << Rule.new(status, max_age, resolver) + end + end + end +end 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 new file mode 100644 index 00000000..7acc4a09 --- /dev/null +++ b/lib/utopia/redirection/middleware.rb @@ -0,0 +1,53 @@ +# 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. + 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 + 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 + + # 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) + # 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 + + return @delegate.call(request) + 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..0e7d5fea 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 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 a520688c..3c3d19a7 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 + 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 46c0f513..994ee623 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 + 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/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/redirection.rb b/test/utopia/redirection.rb index 1076e9f3..4d0b6eb3 100644 --- a/test/utopia/redirection.rb +++ b/test/utopia/redirection.rb @@ -32,15 +32,14 @@ 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.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| @@ -142,4 +162,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