rack-url-canonicalizer 0.1.0 → 0.2.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: 416209bee17b49fe10d7cad5c88885750a91006ab9692e37ecf115655303fa3c
4
+ data.tar.gz: 354611a1a288bfbeb65c504cf5f910b483d50028b4ab8ae7b52f0d5fc68672b2
5
5
  SHA512:
6
- metadata.gz: f407b4ca1f132cc6f87124706e84532d329a94cb9eb0176a20488518e630fb591439f9d1f191c844ddec558065bd248403897159a24f4ea1d1929e79425d1a25
7
- data.tar.gz: d982f92f9dfc76c23b0888ac68b1268fe6a749e8acf57ff0ec9a2888ce686e34cc89c70111f3c511b4f26430cb7dc48fba5f772aa9ee914b8054cbf85e3817c0
6
+ metadata.gz: babc20af66280225ed938bd8f19ee0ce412552b4d39dd79d62101476c3e0526c998103f3cbdde53301057a4268f41b3bde05e6390011ad70d74ec1dd484dc902
7
+ data.tar.gz: a8717237099c617244a9136e5b28c7a28b3452b6a281d0f38522c3b374c6adb9b8b96dd299870ffc639c36d24b1e35b8359b1e506b8369ce5f61418305370230
data/README.md CHANGED
@@ -3,7 +3,7 @@
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
9
  4. Validating `locale` GET query parameters against an allowed list and stripping invalid locales.
@@ -44,10 +44,12 @@ bundle install
44
44
 
45
45
  | Option | Type | Default | Description |
46
46
  | :--- | :--- | :--- | :--- |
47
- | `strip_www` | `Boolean` | `true` | Removes `www.` prefix from host. |
47
+ | `strip_www` | `Boolean` | `true` | Removes `www.` prefix from host. Automatically set to `false` if `enforce_www` is enabled. |
48
+ | `enforce_www` | `Boolean` | `false` | Prepends `www.` to requests lacking it (aliased as `prefer_www`). Automatically skips localhost and IP addresses. |
48
49
  | `collapse_slashes` | `Boolean` | `true` | Replaces multiple slashes `//` with `/`. |
49
50
  | `strip_trailing_slash` | `Boolean` | `true` | Removes trailing slash from path (except root `/`). |
50
51
  | `exclude_paths` | `Array<String>` | `[]` | Array of path prefixes to bypass (e.g. `%w[/api /assets]`). |
52
+ | `exclude_hosts` | `Array<String \| Regexp \| Proc>` | `[]` | Array of hosts or patterns to bypass from host redirection. |
51
53
  | `locale_param` | `String` | `"locale"` | GET parameter key for locale. |
52
54
  | `allowed_locales` | `Array<String> \| Proc` | `nil` | List of allowed locales or callable returning them. |
53
55
  | `redirect_status` | `Integer` | `301` | HTTP status code for redirects. |
@@ -62,12 +64,18 @@ bundle install
62
64
  In `config/application.rb`:
63
65
 
64
66
  ```ruby
67
+ # Strip www (default):
65
68
  config.middleware.use Rack::UrlCanonicalizer,
66
69
  strip_www: true,
67
70
  collapse_slashes: true,
68
71
  strip_trailing_slash: true,
69
72
  exclude_paths: %w[/api /assets /up],
70
73
  allowed_locales: -> { I18n.available_locales }
74
+
75
+ # Or enforce www (example.com -> www.example.com):
76
+ config.middleware.use Rack::UrlCanonicalizer,
77
+ enforce_www: true,
78
+ exclude_hosts: %w[api.example.com]
71
79
  ```
72
80
 
73
81
  ### Sinatra / Plain Rack
@@ -78,7 +86,7 @@ In `config.ru`:
78
86
  require "rack/url_canonicalizer"
79
87
 
80
88
  use Rack::UrlCanonicalizer,
81
- strip_www: true,
89
+ enforce_www: true,
82
90
  exclude_paths: ["/api"]
83
91
 
84
92
  run MyApp
@@ -90,7 +98,7 @@ Alternatively, set global defaults:
90
98
 
91
99
  ```ruby
92
100
  Rack::UrlCanonicalizer.configure do |config|
93
- config.strip_www = true
101
+ config.enforce_www = true
94
102
  config.collapse_slashes = true
95
103
  config.allowed_locales = %w[en ru es]
96
104
  end
@@ -3,26 +3,42 @@
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,
9
8
  :exclude_paths,
9
+ :exclude_hosts,
10
10
  :locale_param,
11
11
  :allowed_locales,
12
12
  :redirect_status,
13
13
  :cache_control
14
+ attr_reader :strip_www, :enforce_www
14
15
 
15
16
  def initialize
16
17
  @strip_www = true
18
+ @enforce_www = false
17
19
  @collapse_slashes = true
18
20
  @strip_trailing_slash = true
19
21
  @exclude_paths = []
20
- @locale_param = "locale"
22
+ @exclude_hosts = []
23
+ @locale_param = 'locale'
21
24
  @allowed_locales = nil
22
25
  @redirect_status = 301
23
- @cache_control = "public, max-age=86400"
26
+ @cache_control = 'public, max-age=86400'
24
27
  end
25
28
 
29
+ def strip_www=(val)
30
+ @strip_www = val
31
+ @enforce_www = false if val
32
+ end
33
+
34
+ def enforce_www=(val)
35
+ @enforce_www = val
36
+ @strip_www = false if val
37
+ end
38
+
39
+ alias prefer_www enforce_www
40
+ alias prefer_www= enforce_www=
41
+
26
42
  def allowed_locales_list
27
43
  return nil if allowed_locales.nil?
28
44
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rack
4
4
  class UrlCanonicalizer
5
- VERSION = "0.1.0"
5
+ VERSION = '0.2.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,110 @@ 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
102
122
  end
103
123
 
104
- def excluded_path?(path_info)
105
- return false if @config.exclude_paths.nil? || @config.exclude_paths.empty?
124
+ def locale_redirect?(query_params)
125
+ key = @config.locale_param
126
+ return false unless key && query_params.key?(key)
106
127
 
107
- @config.exclude_paths.any? do |prefix|
108
- path_info.start_with?(prefix)
109
- end
128
+ allowed = @config.allowed_locales_list
129
+ return false if allowed&.include?(query_params[key].to_s)
130
+
131
+ query_params.delete(key)
132
+ true
110
133
  end
111
134
 
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"
119
- end
135
+ def redirect_url(req, host, path, query_params)
136
+ port = [80, 443].include?(req.port) ? '' : ":#{req.port}"
137
+ query = Rack::Utils.build_nested_query(query_params)
138
+ url = "#{req.scheme}://#{host}#{port}#{path}"
139
+ query.empty? ? url : "#{url}?#{query}"
120
140
  end
121
141
  end
122
142
  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.2.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.18
116
145
  specification_version: 4
117
146
  summary: Rack middleware for URL normalization and SEO canonicalization
118
147
  test_files: []