rack-url-canonicalizer 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad9bcf0b3bfa0b272e1ec7d8eb41e3a98b19460341241c24559150d0f2cdb009
4
- data.tar.gz: 90c19805083edf665a1c84f0b5966fb937f4249df73be65592d8e4359762e1a3
3
+ metadata.gz: 0cc0592b66830e51b4f791b9b7c9503fa98ca0c20f25fb4fe92ae9379af404ed
4
+ data.tar.gz: 572708d83ec547c190e93d78dd900db96af8de7cca0c3760122a132fef8282c6
5
5
  SHA512:
6
- metadata.gz: f407b4ca1f132cc6f87124706e84532d329a94cb9eb0176a20488518e630fb591439f9d1f191c844ddec558065bd248403897159a24f4ea1d1929e79425d1a25
7
- data.tar.gz: d982f92f9dfc76c23b0888ac68b1268fe6a749e8acf57ff0ec9a2888ce686e34cc89c70111f3c511b4f26430cb7dc48fba5f772aa9ee914b8054cbf85e3817c0
6
+ metadata.gz: 1e1b05c26326297a9b6cd425433face278ebeae8d9ca9deddf34f637d092b3f8875d64591cf5b59a0265187933cfcbe88822ef5c0d52a7c30f08b6010c19f0d8
7
+ data.tar.gz: ffc8f3651b5a9bd64a50365dd86e9a5f9129903bfd4e998d44da3a3a8a024f30a60673bfd4e6aea365954cb12299985de08c95bf12df3e0daa04ad9c59c6989e
data/README.md CHANGED
@@ -3,10 +3,11 @@
3
3
  `rack-url-canonicalizer` is a lightweight, zero-dependency Rack middleware for Ruby applications (Rails, Sinatra, Hanami, Roda, or plain Rack).
4
4
 
5
5
  It automatically canonicalizes incoming request URLs to prevent search engine **Duplicate Content** penalties by enforcing:
6
- 1. Removal of the `www.` sub-domain prefix.
6
+ 1. Stripping `www.` or enforcing `www.` sub-domain prefix.
7
7
  2. Collapsing duplicate slashes (`//path//to` ➔ `/path/to`).
8
8
  3. Stripping trailing slashes from paths (`/path/` ➔ `/path`).
9
- 4. Validating `locale` GET query parameters against an allowed list and stripping invalid locales.
9
+ 4. Canonicalizing URL paths to lowercase (`/About-Us` ➔ `/about-us`), globally or selectively.
10
+ 5. Validating `locale` GET query parameters against an allowed list and stripping invalid locales.
10
11
 
11
12
  ---
12
13
 
@@ -16,7 +17,7 @@ Search engines (Google, Yandex, Bing) treat every unique URL string as a distinc
16
17
 
17
18
  This gem is essential when:
18
19
  - **Protecting SEO & Domain Authority**: You need to prevent index cannibalization caused by variations like `example.com/item` vs. `www.example.com/item`, `example.com/item/`, or `example.com//item`.
19
- - **Enforcing Single Canonical URLs**: You want incoming traffic from user typos or legacy backlinks to be redirected with an HTTP 301 (Moved Permanently) status to a single canonical address before reaching your application logic.
20
+ - **Enforcing Single Canonical URLs**: You want incoming traffic from user typos, mobile keyboard auto-capitalization, or legacy backlinks to be redirected with an HTTP 301 (Moved Permanently) status to a single canonical address before reaching your application logic.
20
21
  - **Handling Multi-Locale Query Parameters**: Your app supports localization (e.g. `?locale=en`) and you want to sanitize or discard invalid/malformed locale parameters (e.g. `?locale=xyz`) that would otherwise generate infinite duplicate URL variants.
21
22
  - **High-Performance Early Redirects**: You want URL normalization to happen at the lightweight Rack middleware layer—avoiding unnecessary Rails controller instantiations, routing checks, or database queries.
22
23
  - **Simplified Deployment with Kamal & Modern Stacks**: Ideal for containerized setups deployed with **Kamal**, **Thruster**, or Docker, where configuring and maintaining separate Nginx/Caddy redirect rules or complex DNS edge layers adds unnecessary overhead. This gem handles canonicalization directly inside your application stack for fast, zero-config deployments.
@@ -44,10 +45,15 @@ bundle install
44
45
 
45
46
  | Option | Type | Default | Description |
46
47
  | :--- | :--- | :--- | :--- |
47
- | `strip_www` | `Boolean` | `true` | Removes `www.` prefix from host. |
48
+ | `strip_www` | `Boolean` | `true` | Removes `www.` prefix from host. Automatically set to `false` if `enforce_www` is enabled. |
49
+ | `enforce_www` | `Boolean` | `false` | Prepends `www.` to requests lacking it (aliased as `prefer_www`). Automatically skips localhost and IP addresses. |
48
50
  | `collapse_slashes` | `Boolean` | `true` | Replaces multiple slashes `//` with `/`. |
49
51
  | `strip_trailing_slash` | `Boolean` | `true` | Removes trailing slash from path (except root `/`). |
52
+ | `downcase_path` | `Boolean` | `false` | Converts path to lowercase (e.g. `/About-Us` ➔ `/about-us`). Query string casing is preserved. |
53
+ | `downcase_paths` | `Array<String \| Regexp \| Proc> \| Boolean` | `nil` | Selective allowlist: only paths matching these prefixes or patterns are downcased. |
54
+ | `downcase_exclude_paths` | `Array<String \| Regexp \| Proc>` | `[]` | Array of path prefixes, patterns, or procs to bypass from downcasing (preserves casing for tokens/IDs). |
50
55
  | `exclude_paths` | `Array<String>` | `[]` | Array of path prefixes to bypass (e.g. `%w[/api /assets]`). |
56
+ | `exclude_hosts` | `Array<String \| Regexp \| Proc>` | `[]` | Array of hosts or patterns to bypass from host redirection. |
51
57
  | `locale_param` | `String` | `"locale"` | GET parameter key for locale. |
52
58
  | `allowed_locales` | `Array<String> \| Proc` | `nil` | List of allowed locales or callable returning them. |
53
59
  | `redirect_status` | `Integer` | `301` | HTTP status code for redirects. |
@@ -62,12 +68,31 @@ bundle install
62
68
  In `config/application.rb`:
63
69
 
64
70
  ```ruby
71
+ # Strip www, normalize paths, and enforce lowercase:
65
72
  config.middleware.use Rack::UrlCanonicalizer,
66
73
  strip_www: true,
67
74
  collapse_slashes: true,
68
75
  strip_trailing_slash: true,
76
+ downcase_path: true,
69
77
  exclude_paths: %w[/api /assets /up],
70
78
  allowed_locales: -> { I18n.available_locales }
79
+
80
+ # Or selective downcasing for specific marketing / content routes:
81
+ config.middleware.use Rack::UrlCanonicalizer,
82
+ downcase_paths: [
83
+ "/about",
84
+ "/contribution",
85
+ "/pricing",
86
+ %r{\A/(docs|faq)/}i
87
+ ],
88
+ downcase_exclude_paths: [
89
+ %r{\A/invites/[A-Za-z0-9_-]+}
90
+ ]
91
+
92
+ # Or enforce www (example.com -> www.example.com):
93
+ config.middleware.use Rack::UrlCanonicalizer,
94
+ enforce_www: true,
95
+ exclude_hosts: %w[api.example.com]
71
96
  ```
72
97
 
73
98
  ### Sinatra / Plain Rack
@@ -78,7 +103,8 @@ In `config.ru`:
78
103
  require "rack/url_canonicalizer"
79
104
 
80
105
  use Rack::UrlCanonicalizer,
81
- strip_www: true,
106
+ downcase_path: true,
107
+ enforce_www: true,
82
108
  exclude_paths: ["/api"]
83
109
 
84
110
  run MyApp
@@ -90,8 +116,9 @@ Alternatively, set global defaults:
90
116
 
91
117
  ```ruby
92
118
  Rack::UrlCanonicalizer.configure do |config|
93
- config.strip_www = true
119
+ config.enforce_www = true
94
120
  config.collapse_slashes = true
121
+ config.downcase_path = true
95
122
  config.allowed_locales = %w[en ru es]
96
123
  end
97
124
 
@@ -3,26 +3,48 @@
3
3
  module Rack
4
4
  class UrlCanonicalizer
5
5
  class Configuration
6
- attr_accessor :strip_www,
7
- :collapse_slashes,
6
+ attr_accessor :collapse_slashes,
8
7
  :strip_trailing_slash,
8
+ :downcase_path,
9
+ :downcase_paths,
10
+ :downcase_exclude_paths,
9
11
  :exclude_paths,
12
+ :exclude_hosts,
10
13
  :locale_param,
11
14
  :allowed_locales,
12
15
  :redirect_status,
13
16
  :cache_control
17
+ attr_reader :strip_www, :enforce_www
14
18
 
15
19
  def initialize
16
20
  @strip_www = true
21
+ @enforce_www = false
17
22
  @collapse_slashes = true
18
23
  @strip_trailing_slash = true
24
+ @downcase_path = false
25
+ @downcase_paths = nil
26
+ @downcase_exclude_paths = []
19
27
  @exclude_paths = []
20
- @locale_param = "locale"
28
+ @exclude_hosts = []
29
+ @locale_param = 'locale'
21
30
  @allowed_locales = nil
22
31
  @redirect_status = 301
23
- @cache_control = "public, max-age=86400"
32
+ @cache_control = 'public, max-age=86400'
24
33
  end
25
34
 
35
+ def strip_www=(val)
36
+ @strip_www = val
37
+ @enforce_www = false if val
38
+ end
39
+
40
+ def enforce_www=(val)
41
+ @enforce_www = val
42
+ @strip_www = false if val
43
+ end
44
+
45
+ alias prefer_www enforce_www
46
+ alias prefer_www= enforce_www=
47
+
26
48
  def allowed_locales_list
27
49
  return nil if allowed_locales.nil?
28
50
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rack
4
4
  class UrlCanonicalizer
5
- VERSION = "0.1.0"
5
+ VERSION = '0.3.0'
6
6
  end
7
7
  end
@@ -1,12 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rack"
4
- require_relative "url_canonicalizer/version"
5
- require_relative "url_canonicalizer/configuration"
6
- require_relative "url_canonicalizer/railtie"
3
+ require 'rack'
4
+ require_relative 'url_canonicalizer/version'
5
+ require_relative 'url_canonicalizer/configuration'
6
+ require_relative 'url_canonicalizer/railtie'
7
7
 
8
8
  module Rack
9
9
  class UrlCanonicalizer
10
+ IPV4_REGEX = /\A\d{1,3}(?:\.\d{1,3}){3}\z/
11
+ REDIRECT_BODIES = {
12
+ 301 => 'Moved Permanently',
13
+ 302 => 'Found',
14
+ 307 => 'Temporary Redirect',
15
+ 308 => 'Permanent Redirect'
16
+ }.freeze
17
+
10
18
  class << self
11
19
  def configuration
12
20
  @configuration ||= Configuration.new
@@ -25,98 +33,142 @@ module Rack
25
33
  @app = app
26
34
  @config = self.class.configuration.dup
27
35
 
36
+ validate_options!(options)
37
+
28
38
  options.each do |key, value|
29
- writer = "#{key}="
30
- @config.send(writer, value) if @config.respond_to?(writer)
39
+ @config.public_send("#{key}=", value) if @config.respond_to?("#{key}=")
31
40
  end
32
41
  end
33
42
 
34
43
  def call(env)
35
44
  req = Rack::Request.new(env)
45
+ return @app.call(env) unless (req.get? || req.head?) && !xhr_request?(req, env)
36
46
 
37
- return @app.call(env) unless req.get? || req.head?
38
- return @app.call(env) if xhr_request?(req, env)
47
+ path = env['PATH_INFO'] || ''
48
+ return @app.call(env) if excluded_path?(path)
39
49
 
40
- path_info = env["PATH_INFO"] || ""
50
+ target_host, host_redirect = normalize_host(req.host || '')
51
+ norm_path = normalize_path(path)
52
+ query_params = req.GET.dup
53
+ locale_redirect = locale_redirect?(query_params)
54
+
55
+ return @app.call(env) unless host_redirect || path != norm_path || locale_redirect
56
+
57
+ [
58
+ @config.redirect_status,
59
+ {
60
+ 'location' => redirect_url(req, target_host, norm_path, query_params),
61
+ 'content-type' => 'text/html',
62
+ 'cache-control' => @config.cache_control
63
+ },
64
+ [REDIRECT_BODIES.fetch(@config.redirect_status, 'Redirected')]
65
+ ]
66
+ end
41
67
 
42
- if excluded_path?(path_info)
43
- return @app.call(env)
44
- end
68
+ private
45
69
 
46
- host = req.host || ""
47
- host_redirect = @config.strip_www && host.start_with?("www.")
48
- target_host = host_redirect ? host.sub(/\Awww\./, "") : host
70
+ def validate_options!(options)
71
+ strip = options[:strip_www] || options['strip_www']
72
+ enforce = options[:enforce_www] || options['enforce_www'] || options[:prefer_www] || options['prefer_www']
49
73
 
50
- raw_path = path_info
51
- normalized_path = raw_path.dup
74
+ raise ArgumentError, 'Conflicting options: cannot enable both :strip_www and :enforce_www' if strip && enforce
75
+ end
52
76
 
53
- if @config.collapse_slashes
54
- normalized_path.gsub!(%r{/{2,}}, "/")
55
- end
77
+ def xhr_request?(req, env)
78
+ return true if req.respond_to?(:xhr?) && req.xhr?
56
79
 
57
- if @config.strip_trailing_slash && normalized_path.length > 1 && normalized_path.end_with?("/")
58
- normalized_path.chomp!("/")
59
- end
80
+ env['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'
81
+ end
60
82
 
61
- query_params = req.GET.dup
62
- locale_redirect = false
63
- locale_key = @config.locale_param
64
-
65
- if locale_key && query_params.key?(locale_key)
66
- allowed = @config.allowed_locales_list
67
- if allowed && !allowed.include?(query_params[locale_key].to_s)
68
- query_params.delete(locale_key)
69
- locale_redirect = true
83
+ def excluded_path?(path_info)
84
+ return false if @config.exclude_paths.nil? || @config.exclude_paths.empty?
85
+
86
+ @config.exclude_paths.any? { |prefix| path_info.start_with?(prefix) }
87
+ end
88
+
89
+ def excluded_host?(host)
90
+ return false if @config.exclude_hosts.nil? || @config.exclude_hosts.empty?
91
+
92
+ @config.exclude_hosts.any? do |pattern|
93
+ case pattern
94
+ when Regexp then pattern.match?(host)
95
+ when Proc then pattern.call(host)
96
+ else pattern.to_s.casecmp?(host)
70
97
  end
71
98
  end
99
+ end
72
100
 
73
- if host_redirect || raw_path != normalized_path || locale_redirect
74
- scheme = req.scheme
75
- port = req.port
76
- port_part = [ 80, 443 ].include?(port) ? "" : ":#{port}"
77
-
78
- new_query = Rack::Utils.build_nested_query(query_params)
79
- new_url = +"#{scheme}://#{target_host}#{port_part}#{normalized_path}"
80
- new_url << "?#{new_query}" unless new_query.empty?
81
-
82
- return [
83
- @config.redirect_status,
84
- {
85
- "location" => new_url,
86
- "content-type" => "text/html",
87
- "cache-control" => @config.cache_control
88
- },
89
- [ redirect_body(@config.redirect_status) ]
90
- ]
91
- end
101
+ def normalize_host(host)
102
+ return [host, false] if excluded_host?(host)
103
+ return [host.sub(/\Awww\./i, ''), true] if @config.strip_www && host.downcase.start_with?('www.')
104
+ return ["www.#{host}", true] if @config.enforce_www && should_enforce_www?(host)
92
105
 
93
- @app.call(env)
106
+ [host, false]
94
107
  end
95
108
 
96
- private
109
+ def should_enforce_www?(host)
110
+ return false if host.empty? ||
111
+ host.downcase.start_with?('www.') ||
112
+ host == 'localhost' ||
113
+ host.end_with?('.localhost', '.local', '.test')
97
114
 
98
- def xhr_request?(req, env)
99
- return true if req.respond_to?(:xhr?) && req.xhr?
115
+ !host.match?(IPV4_REGEX) && !host.include?(':')
116
+ end
100
117
 
101
- env["HTTP_X_REQUESTED_WITH"] == "XMLHttpRequest"
118
+ def normalize_path(path)
119
+ path = path.gsub(%r{/{2,}}, '/') if @config.collapse_slashes
120
+ path = path.chomp('/') if @config.strip_trailing_slash && path.length > 1 && path.end_with?('/')
121
+ path = path.downcase if should_downcase_path?(path)
122
+ path
102
123
  end
103
124
 
104
- def excluded_path?(path_info)
105
- return false if @config.exclude_paths.nil? || @config.exclude_paths.empty?
125
+ def should_downcase_path?(path)
126
+ return false if downcase_excluded?(path)
106
127
 
107
- @config.exclude_paths.any? do |prefix|
108
- path_info.start_with?(prefix)
128
+ if @config.downcase_paths == true
129
+ true
130
+ elsif @config.downcase_paths
131
+ match_path_patterns?(@config.downcase_paths, path)
132
+ else
133
+ @config.downcase_path
109
134
  end
110
135
  end
111
136
 
112
- def redirect_body(status)
113
- case status
114
- when 301 then "Moved Permanently"
115
- when 302 then "Found"
116
- when 307 then "Temporary Redirect"
117
- when 308 then "Permanent Redirect"
118
- else "Redirected"
137
+ def downcase_excluded?(path)
138
+ return false if @config.downcase_exclude_paths.nil? || @config.downcase_exclude_paths.empty?
139
+
140
+ match_path_patterns?(@config.downcase_exclude_paths, path)
141
+ end
142
+
143
+ def match_path_patterns?(patterns, path)
144
+ Array(patterns).any? do |pattern|
145
+ case pattern
146
+ when Regexp then pattern.match?(path) || pattern.match?(path.downcase)
147
+ when Proc then pattern.call(path)
148
+ else
149
+ prefix = pattern.to_s.downcase
150
+ prefix = "/#{prefix}" unless prefix.start_with?('/')
151
+ path.downcase.start_with?(prefix)
152
+ end
119
153
  end
120
154
  end
155
+
156
+ def locale_redirect?(query_params)
157
+ key = @config.locale_param
158
+ return false unless key && query_params.key?(key)
159
+
160
+ allowed = @config.allowed_locales_list
161
+ return false if allowed&.include?(query_params[key].to_s)
162
+
163
+ query_params.delete(key)
164
+ true
165
+ end
166
+
167
+ def redirect_url(req, host, path, query_params)
168
+ port = [80, 443].include?(req.port) ? '' : ":#{req.port}"
169
+ query = Rack::Utils.build_nested_query(query_params)
170
+ url = "#{req.scheme}://#{host}#{port}#{path}"
171
+ query.empty? ? url : "#{url}?#{query}"
172
+ end
121
173
  end
122
174
  end
@@ -1,3 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rack/url_canonicalizer"
3
+ require 'rack/url_canonicalizer'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-url-canonicalizer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Abramov
@@ -37,6 +37,34 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: parallel
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: 2.0.0
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "<"
52
+ - !ruby/object:Gem::Version
53
+ version: 2.0.0
54
+ - !ruby/object:Gem::Dependency
55
+ name: rack-test
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '2.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '2.0'
40
68
  - !ruby/object:Gem::Dependency
41
69
  name: rake
42
70
  requirement: !ruby/object:Gem::Requirement
@@ -66,19 +94,19 @@ dependencies:
66
94
  - !ruby/object:Gem::Version
67
95
  version: '3.0'
68
96
  - !ruby/object:Gem::Dependency
69
- name: rack-test
97
+ name: rubocop
70
98
  requirement: !ruby/object:Gem::Requirement
71
99
  requirements:
72
- - - ">="
100
+ - - "~>"
73
101
  - !ruby/object:Gem::Version
74
- version: '2.0'
102
+ version: '1.0'
75
103
  type: :development
76
104
  prerelease: false
77
105
  version_requirements: !ruby/object:Gem::Requirement
78
106
  requirements:
79
- - - ">="
107
+ - - "~>"
80
108
  - !ruby/object:Gem::Version
81
- version: '2.0'
109
+ version: '1.0'
82
110
  description: Removes www, collapses slashes, strips trailing slashes, and validates
83
111
  locale params to prevent duplicate content SEO penalties.
84
112
  email:
@@ -97,7 +125,8 @@ files:
97
125
  homepage: https://github.com/o-200/rack-url-canonicalizer
98
126
  licenses:
99
127
  - MIT
100
- metadata: {}
128
+ metadata:
129
+ rubygems_mfa_required: 'true'
101
130
  rdoc_options: []
102
131
  require_paths:
103
132
  - lib
@@ -112,7 +141,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
112
141
  - !ruby/object:Gem::Version
113
142
  version: '0'
114
143
  requirements: []
115
- rubygems_version: 4.0.16
144
+ rubygems_version: 4.0.20
116
145
  specification_version: 4
117
146
  summary: Rack middleware for URL normalization and SEO canonicalization
118
147
  test_files: []