url_canonicalize 1.0.2 → 2.0.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 +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +110 -19
- data/lib/url_canonicalize/client.rb +21 -0
- data/lib/{monkey_patches/addressable/uri.rb → url_canonicalize/core_ext/addressable.rb} +4 -2
- data/lib/{monkey_patches → url_canonicalize/core_ext}/string.rb +2 -2
- data/lib/{monkey_patches → url_canonicalize/core_ext}/uri.rb +4 -2
- data/lib/url_canonicalize/core_ext.rb +6 -0
- data/lib/url_canonicalize/http.rb +45 -49
- data/lib/url_canonicalize/options.rb +35 -2
- data/lib/url_canonicalize/request.rb +14 -16
- data/lib/url_canonicalize/result.rb +28 -0
- data/lib/url_canonicalize/transport.rb +47 -0
- data/lib/url_canonicalize/uri.rb +20 -4
- data/lib/url_canonicalize/version.rb +1 -1
- data/lib/url_canonicalize.rb +7 -8
- metadata +9 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 503c645a9625985b4391f2743587f148d750d09d43d473c7a9923be2a2ec8c7f
|
|
4
|
+
data.tar.gz: 3983c156ec6754bac592880a968a07af984a5c11109ab31d096376c0774584a2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b85dc8c4af5866b0bd9d79c92181672d3401419587f0d6868fdce28cfc2c1e973c86e208c633d3062b09dcad9e9e4794badff12b157c5e6cf0a3a95a0426f041
|
|
7
|
+
data.tar.gz: 498beaf7ca0e0a56d0f2f9b3280c42b7417ff8a14f38453ae757a480cd78388e5391ed7c3896a596648796226be8e3ee745446a412217a51f36b3025bf2a6295
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,60 @@ and this project adheres to
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [2.0.0] - 2026-07-25
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `URLCanonicalize::Client`, a reusable, thread-safe client whose options are
|
|
16
|
+
validated and frozen at construction; request headers, a logger and the
|
|
17
|
+
transport are configurable, so tests can inject a fake transport or
|
|
18
|
+
resolver
|
|
19
|
+
([#186](https://github.com/dominicsayers/url_canonicalize/issues/186))
|
|
20
|
+
- Documented, table-tested URL normalization per RFC 3986: lowercase scheme
|
|
21
|
+
and host, punycode hosts, normalized percent-encoding, resolved dot
|
|
22
|
+
segments, removed default ports and stripped fragments, applied to the
|
|
23
|
+
input, every hop and the returned URL
|
|
24
|
+
([#187](https://github.com/dominicsayers/url_canonicalize/issues/187))
|
|
25
|
+
- SonarQube quality gate badge in the README
|
|
26
|
+
([#196](https://github.com/dominicsayers/url_canonicalize/issues/196))
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **Breaking:** the core-class extensions (`String#canonicalize` and friends)
|
|
31
|
+
are opt-in via `require 'url_canonicalize/core_ext'`; requiring the gem
|
|
32
|
+
alone no longer patches anything
|
|
33
|
+
([#186](https://github.com/dominicsayers/url_canonicalize/issues/186))
|
|
34
|
+
- **Breaking:** `URLCanonicalize.fetch` returns an immutable
|
|
35
|
+
`URLCanonicalize::Result` exposing the canonical URL, the confirming
|
|
36
|
+
response, the parsed HTML, the request chain and the discovery source
|
|
37
|
+
([#186](https://github.com/dominicsayers/url_canonicalize/issues/186))
|
|
38
|
+
- **Breaking:** returned URLs are normalized (an empty path becomes `/`) and
|
|
39
|
+
a scheme is recognized only as a leading `scheme:` token, so a colon later
|
|
40
|
+
in a bare host's path no longer causes a parse error
|
|
41
|
+
([#187](https://github.com/dominicsayers/url_canonicalize/issues/187))
|
|
42
|
+
- **Breaking:** Ruby 3.3 or later is required; CI tests Ruby 3.3, 3.4 and
|
|
43
|
+
4.0 on GitHub Actions and the Travis and CircleCI configurations are gone
|
|
44
|
+
([#188](https://github.com/dominicsayers/url_canonicalize/issues/188))
|
|
45
|
+
- Debug output moved from the `DEBUG` environment variable to the `logger`
|
|
46
|
+
option
|
|
47
|
+
([#186](https://github.com/dominicsayers/url_canonicalize/issues/186))
|
|
48
|
+
- Update nokogiri to 1.19.4, fixing all eleven open Dependabot alerts
|
|
49
|
+
([#210](https://github.com/dominicsayers/url_canonicalize/issues/210))
|
|
50
|
+
- Replace the `bin/release` scripts with the
|
|
51
|
+
[`release_ceremony`](https://github.com/dominicsayers/release_ceremony) gem
|
|
52
|
+
they were extracted into; the release commands are now
|
|
53
|
+
`bundle exec release_ceremony prepare|publish VERSION`
|
|
54
|
+
|
|
55
|
+
### Removed
|
|
56
|
+
|
|
57
|
+
- Hound and CodeClimate configuration; RuboCop in CI and SonarQube Cloud
|
|
58
|
+
cover static analysis
|
|
59
|
+
([#211](https://github.com/dominicsayers/url_canonicalize/issues/211),
|
|
60
|
+
[#195](https://github.com/dominicsayers/url_canonicalize/issues/195))
|
|
61
|
+
- The RuboCop to-do file; its offences are fixed and the deliberate spec
|
|
62
|
+
limits are explicit policy in `.rubocop.yml`
|
|
63
|
+
([#198](https://github.com/dominicsayers/url_canonicalize/issues/198))
|
|
64
|
+
|
|
11
65
|
## [1.0.2] - 2026-07-24
|
|
12
66
|
|
|
13
67
|
### Fixed
|
|
@@ -89,7 +143,8 @@ changelog format. Their history is available from the
|
|
|
89
143
|
and the
|
|
90
144
|
[commit log](https://github.com/dominicsayers/url_canonicalize/commits/v0.1.15).
|
|
91
145
|
|
|
92
|
-
[Unreleased]: https://github.com/dominicsayers/url_canonicalize/compare/
|
|
146
|
+
[Unreleased]: https://github.com/dominicsayers/url_canonicalize/compare/v2.0.0...HEAD
|
|
147
|
+
[2.0.0]: https://github.com/dominicsayers/url_canonicalize/compare/v1.0.2...v2.0.0
|
|
93
148
|
[1.0.2]: https://github.com/dominicsayers/url_canonicalize/compare/v1.0.1...v1.0.2
|
|
94
149
|
[1.0.1]: https://github.com/dominicsayers/url_canonicalize/compare/v1.0.0...v1.0.1
|
|
95
150
|
[1.0.0]: https://github.com/dominicsayers/url_canonicalize/compare/v0.2.1...v1.0.0
|
data/README.md
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# URLCanonicalize
|
|
2
2
|
[](https://rubygems.org/gems/url_canonicalize)
|
|
3
3
|
[](https://rubygems.org/gems/url_canonicalize)
|
|
4
|
-
[](https://github.com/dominicsayers/url_canonicalize/actions/workflows/test.yml)
|
|
5
5
|
[](https://github.com/dominicsayers/url_canonicalize/actions/workflows/codeql-analysis.yml)
|
|
6
|
+
[](https://sonarcloud.io/summary/new_code?id=dominicsayers_url_canonicalize)
|
|
6
7
|
|
|
7
8
|
URLCanonicalize is a Ruby gem that finds the canonical version of a URL. It
|
|
8
|
-
provides
|
|
9
|
-
Addressable::URI classes.
|
|
9
|
+
provides a configurable client API, and optional `canonicalize` extension
|
|
10
|
+
methods for the String, URI::HTTP, URI::HTTPS and Addressable::URI classes.
|
|
10
11
|
|
|
11
|
-
Ruby 3.
|
|
12
|
+
Ruby 3.3 or later is required.
|
|
12
13
|
|
|
13
14
|
## Installation
|
|
14
15
|
|
|
@@ -20,33 +21,123 @@ gem 'url_canonicalize'
|
|
|
20
21
|
|
|
21
22
|
## Usage
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
The primary API is a reusable client. Its options are validated and frozen at
|
|
25
|
+
construction, so one client can be shared safely between threads:
|
|
25
26
|
|
|
26
27
|
```ruby
|
|
27
|
-
|
|
28
|
+
client = URLCanonicalize::Client.new(
|
|
29
|
+
total_timeout: 10,
|
|
30
|
+
max_redirects: 8,
|
|
31
|
+
max_body_bytes: 512_000
|
|
32
|
+
)
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
client.canonicalize('http://www.twitter.com') # => 'https://twitter.com/'
|
|
35
|
+
```
|
|
30
36
|
|
|
31
|
-
|
|
37
|
+
For one-off calls the module methods accept the same options and build a
|
|
38
|
+
client internally:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
URLCanonicalize.canonicalize('http://www.twitter.com') # => 'https://twitter.com/'
|
|
42
|
+
URLCanonicalize.fetch('http://www.twitter.com') # => #<URLCanonicalize::Result ...>
|
|
32
43
|
```
|
|
33
44
|
|
|
34
|
-
|
|
35
|
-
|
|
45
|
+
`canonicalize` returns the canonical URL as a string. `fetch` returns an
|
|
46
|
+
immutable `URLCanonicalize::Result` with:
|
|
47
|
+
|
|
48
|
+
- `url` — the canonical URL;
|
|
49
|
+
- `response` — the `Net::HTTPResponse` that confirmed it;
|
|
50
|
+
- `html` — the parsed Nokogiri document when the response was HTML;
|
|
51
|
+
- `chain` — the request chain: each hop's URL and how it was discovered
|
|
52
|
+
(`:initial`, `:redirect` or `:canonical_link`);
|
|
53
|
+
- `source` — how the final URL was discovered.
|
|
54
|
+
|
|
55
|
+
### Configuration
|
|
56
|
+
|
|
57
|
+
Beyond the security limits described below, the client accepts:
|
|
58
|
+
|
|
59
|
+
- `headers` — the request headers to send (replaces the default browser-like
|
|
60
|
+
`User-Agent` and `Accept-Language`);
|
|
61
|
+
- `logger` — any object responding to `debug`; each request and discovered
|
|
62
|
+
canonical URL is logged. Nothing is logged by default;
|
|
63
|
+
- `transport` — anything responding to `call(uri, options)` and returning a
|
|
64
|
+
`Net::HTTP`-compatible connection. The default transport resolves and
|
|
65
|
+
validates the destination address (see below) and configures TLS and
|
|
66
|
+
timeouts. Tests can inject a fake transport to avoid the network entirely;
|
|
67
|
+
a custom resolver can be injected with
|
|
68
|
+
`URLCanonicalize::Transport.new(resolver: ...)`.
|
|
69
|
+
|
|
70
|
+
### Core-class extensions
|
|
71
|
+
|
|
72
|
+
The core-class extensions are opt-in. Requiring `url_canonicalize/core_ext`
|
|
73
|
+
extends `String`, `URI::HTTP`, `URI::HTTPS` and `Addressable::URI` with a
|
|
74
|
+
`canonicalize` method:
|
|
36
75
|
|
|
37
76
|
```ruby
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
)
|
|
77
|
+
require 'url_canonicalize/core_ext'
|
|
78
|
+
|
|
79
|
+
'http://www.twitter.com'.canonicalize # => 'https://twitter.com/'
|
|
80
|
+
|
|
81
|
+
URI('http://www.twitter.com').canonicalize # => #<URI::HTTP:0x00000008767908 URL:https://twitter.com/>
|
|
82
|
+
|
|
83
|
+
Addressable::URI.canonicalize('http://www.twitter.com') # => #<Addressable::URI:0x43c9 URI:https://twitter.com/>
|
|
43
84
|
|
|
44
85
|
'https://example.com/article'.canonicalize(total_timeout: 10)
|
|
45
86
|
```
|
|
46
87
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
88
|
+
Requiring `url_canonicalize` alone no longer patches any core class: if you
|
|
89
|
+
relied on the extensions, add the extra require.
|
|
90
|
+
|
|
91
|
+
## Normalization and canonicalization
|
|
92
|
+
|
|
93
|
+
URLCanonicalize distinguishes two operations:
|
|
94
|
+
|
|
95
|
+
- **Normalization** is syntactic: rewriting a URL into its RFC 3986 normal
|
|
96
|
+
form without any network access. Parsing and reference resolution use
|
|
97
|
+
Ruby's `URI` (strict); normalization uses `Addressable::URI#normalize`.
|
|
98
|
+
- **Canonicalization** is discovery: fetching the URL, following redirects
|
|
99
|
+
and server-declared canonical links, and returning the normalized final
|
|
100
|
+
URL.
|
|
101
|
+
|
|
102
|
+
Every URL is processed in this order:
|
|
103
|
+
|
|
104
|
+
1. The input is stripped of surrounding whitespace and parsed. A leading
|
|
105
|
+
`scheme:` token is honored; an input without one is treated as a host
|
|
106
|
+
name and given `http://`. A colon elsewhere in the URL does not suppress
|
|
107
|
+
the default scheme. Non-HTTP(S) schemes raise
|
|
108
|
+
`URLCanonicalize::Exception::URI`.
|
|
109
|
+
2. The URL is normalized (see below) and fetched with a `HEAD` request,
|
|
110
|
+
retried as `GET` where the server rejects `HEAD`.
|
|
111
|
+
3. Redirects (any `3xx` with a usable `Location`, temporary or permanent)
|
|
112
|
+
are followed. The `Location` value is resolved against the request URL
|
|
113
|
+
per RFC 3986, so absolute, protocol-relative, root-relative,
|
|
114
|
+
path-relative and query-only forms all work.
|
|
115
|
+
4. On a successful response, a canonical link is looked for in the `Link`
|
|
116
|
+
response header first, then in the HTML `<link rel="canonical">` element;
|
|
117
|
+
**the header wins when they conflict**. HTML links resolve against the
|
|
118
|
+
document's `<base href>` when one is declared, otherwise against the
|
|
119
|
+
request URL.
|
|
120
|
+
5. A declared canonical URL is not trusted blindly: it is fetched and
|
|
121
|
+
validated itself. If fetching it fails, the declared canonical URL is
|
|
122
|
+
still returned, confirmed by the response that declared it.
|
|
123
|
+
6. Redirects and followed canonical links share one hop budget and one
|
|
124
|
+
visited-URL set, so cycles and over-long chains terminate
|
|
125
|
+
deterministically. The returned URL is always normalized.
|
|
126
|
+
|
|
127
|
+
Normalization makes these changes and no others:
|
|
128
|
+
|
|
129
|
+
| Component | Rule |
|
|
130
|
+
| --- | --- |
|
|
131
|
+
| Scheme and host | Lowercased |
|
|
132
|
+
| Internationalized host | Converted to punycode (`xn--…`) |
|
|
133
|
+
| Default port (`:80`/`:443`) | Removed; other ports preserved |
|
|
134
|
+
| Dot segments (`/./`, `/../`) | Resolved |
|
|
135
|
+
| Percent-encoding | Uppercase hex; unreserved characters decoded; invalid characters encoded |
|
|
136
|
+
| Empty path | Replaced by `/` |
|
|
137
|
+
| Trailing slash | Preserved (`/a/` and `/a` stay distinct) |
|
|
138
|
+
| Query string | Preserved verbatim — parameters are never reordered or dropped |
|
|
139
|
+
| Fragment (`#…`) | Stripped — fragments are client-side state |
|
|
140
|
+
| Userinfo (`user:pass@`) | Rejected as a security error at fetch time |
|
|
50
141
|
|
|
51
142
|
## Security and limits
|
|
52
143
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module URLCanonicalize
|
|
4
|
+
# A reusable canonicalization client. The options are validated and frozen
|
|
5
|
+
# at construction, so one client can be shared safely between threads
|
|
6
|
+
class Client
|
|
7
|
+
attr_reader :options
|
|
8
|
+
|
|
9
|
+
def initialize(**values)
|
|
10
|
+
@options = URLCanonicalize::Options.new(**values)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def canonicalize(url)
|
|
14
|
+
fetch(url).url
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch(url)
|
|
18
|
+
URLCanonicalize::HTTP.new(url, options: options).fetch
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'addressable/uri'
|
|
4
|
+
|
|
3
5
|
module Addressable
|
|
4
6
|
# Patch for Addressable's URI class
|
|
5
7
|
class URI
|
|
6
|
-
def self.canonicalize(uri, **
|
|
8
|
+
def self.canonicalize(uri, **)
|
|
7
9
|
url = parse(uri).to_s # uri can be anything Addressable::URI can handle
|
|
8
|
-
canonical_url = URLCanonicalize.canonicalize(url, **
|
|
10
|
+
canonical_url = URLCanonicalize.canonicalize(url, **)
|
|
9
11
|
parse(canonical_url)
|
|
10
12
|
end
|
|
11
13
|
end
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
3
5
|
module URI
|
|
4
6
|
# URI having the HTTP protocol
|
|
5
7
|
class HTTP
|
|
6
|
-
def canonicalize(**
|
|
7
|
-
new_url = URLCanonicalize.canonicalize(to_s, **
|
|
8
|
+
def canonicalize(**)
|
|
9
|
+
new_url = URLCanonicalize.canonicalize(to_s, **)
|
|
8
10
|
::URI.parse(new_url)
|
|
9
11
|
end
|
|
10
12
|
end
|
|
@@ -4,11 +4,12 @@ module URLCanonicalize
|
|
|
4
4
|
# Persistent connection for possible repeated requests to the same host
|
|
5
5
|
class HTTP
|
|
6
6
|
def fetch
|
|
7
|
-
::Timeout.timeout(
|
|
7
|
+
final = ::Timeout.timeout(
|
|
8
8
|
options[:total_timeout],
|
|
9
9
|
URLCanonicalize::Exception::Timeout,
|
|
10
10
|
"Canonicalization exceeded #{options[:total_timeout]} seconds"
|
|
11
11
|
) { fetch_within_deadline }
|
|
12
|
+
build_result(final)
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def uri
|
|
@@ -24,10 +25,22 @@ module URLCanonicalize
|
|
|
24
25
|
http.request(http_request) { |response| read_response_body(http_request, response) }
|
|
25
26
|
end
|
|
26
27
|
|
|
28
|
+
attr_reader :options
|
|
29
|
+
|
|
27
30
|
private
|
|
28
31
|
|
|
29
32
|
attr_accessor :last_known_good
|
|
30
|
-
|
|
33
|
+
|
|
34
|
+
def initialize(raw_url, options: nil, **values)
|
|
35
|
+
raise ArgumentError, 'Pass either options: or keyword options, not both' if options && !values.empty?
|
|
36
|
+
|
|
37
|
+
@raw_url = raw_url
|
|
38
|
+
@options = options || URLCanonicalize::Options.new(**values)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build_result(final)
|
|
42
|
+
Result.new(url: final.url, response: final.response, html: final.html, chain: chain, source: @final_source)
|
|
43
|
+
end
|
|
31
44
|
|
|
32
45
|
def fetch_within_deadline
|
|
33
46
|
loop do
|
|
@@ -36,11 +49,6 @@ module URLCanonicalize
|
|
|
36
49
|
end
|
|
37
50
|
end
|
|
38
51
|
|
|
39
|
-
def initialize(raw_url, **options)
|
|
40
|
-
@raw_url = raw_url
|
|
41
|
-
@options = URLCanonicalize::Options.new(**options)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
52
|
# Fetch the response
|
|
45
53
|
def response
|
|
46
54
|
@response ||= fetch_response
|
|
@@ -85,47 +93,60 @@ module URLCanonicalize
|
|
|
85
93
|
|
|
86
94
|
def handle_redirect
|
|
87
95
|
reason = hop_refusal_reason
|
|
88
|
-
return follow_hop unless reason
|
|
96
|
+
return follow_hop(:redirect) unless reason
|
|
89
97
|
|
|
90
|
-
|
|
98
|
+
finish_with_last_known_good || raise(URLCanonicalize::Exception::Redirect, reason)
|
|
91
99
|
end
|
|
92
100
|
|
|
93
101
|
def handle_canonical_found
|
|
94
102
|
self.last_known_good = response.response
|
|
95
|
-
return
|
|
103
|
+
return finish_with_last_known_good if hop_refusal_reason
|
|
104
|
+
|
|
105
|
+
follow_hop(:canonical_link)
|
|
106
|
+
end
|
|
96
107
|
|
|
97
|
-
|
|
108
|
+
# A terminal response whose URL came from a server-declared canonical link
|
|
109
|
+
def finish_with_last_known_good
|
|
110
|
+
return unless last_known_good
|
|
111
|
+
|
|
112
|
+
@final_source = :canonical_link
|
|
113
|
+
last_known_good
|
|
98
114
|
end
|
|
99
115
|
|
|
100
116
|
# Redirects and followed canonical links share one visited-URL set and one
|
|
101
117
|
# hop budget, so any cycle or over-long chain terminates deterministically
|
|
102
118
|
def hop_refusal_reason
|
|
103
|
-
return 'Redirect loop detected' if visited
|
|
119
|
+
return 'Redirect loop detected' if visited?(response_url)
|
|
104
120
|
return "#{hops + 1} redirects is too many" if hops >= options[:max_redirects]
|
|
105
121
|
|
|
106
122
|
nil
|
|
107
123
|
end
|
|
108
124
|
|
|
109
|
-
def follow_hop
|
|
110
|
-
|
|
111
|
-
@hops = hops + 1
|
|
125
|
+
def follow_hop(via)
|
|
126
|
+
chain << Result::Hop.new(url: response_url, via: via)
|
|
112
127
|
self.url = response_url
|
|
113
128
|
nil
|
|
114
129
|
end
|
|
115
130
|
|
|
116
|
-
|
|
117
|
-
|
|
131
|
+
# The URLs requested so far and how each one was discovered
|
|
132
|
+
def chain
|
|
133
|
+
@chain ||= [Result::Hop.new(url: url, via: :initial)]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def visited?(candidate)
|
|
137
|
+
chain.any? { |hop| hop.url == candidate }
|
|
118
138
|
end
|
|
119
139
|
|
|
120
140
|
def hops
|
|
121
|
-
|
|
141
|
+
chain.length - 1
|
|
122
142
|
end
|
|
123
143
|
|
|
124
144
|
def handle_failure
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
145
|
+
finish_with_last_known_good || raise(
|
|
146
|
+
URLCanonicalize::Exception::Failure,
|
|
147
|
+
"#{response.failure_class}: #{response.message}",
|
|
148
|
+
cause: response.error
|
|
149
|
+
)
|
|
129
150
|
end
|
|
130
151
|
|
|
131
152
|
def handle_unhandled_response
|
|
@@ -133,6 +154,7 @@ module URLCanonicalize
|
|
|
133
154
|
end
|
|
134
155
|
|
|
135
156
|
def handle_success
|
|
157
|
+
@final_source = chain.last.via
|
|
136
158
|
self.last_known_good = response
|
|
137
159
|
end
|
|
138
160
|
|
|
@@ -145,7 +167,7 @@ module URLCanonicalize
|
|
|
145
167
|
return @http if same_origin? # reuse connection
|
|
146
168
|
|
|
147
169
|
@previous = uri
|
|
148
|
-
@http =
|
|
170
|
+
@http = options[:transport].call(uri, options)
|
|
149
171
|
end
|
|
150
172
|
|
|
151
173
|
def same_origin?
|
|
@@ -156,16 +178,6 @@ module URLCanonicalize
|
|
|
156
178
|
@previous ||= Struct.new(:scheme, :host, :port).new
|
|
157
179
|
end
|
|
158
180
|
|
|
159
|
-
def new_http
|
|
160
|
-
h = Net::HTTP.new uri.host, uri.port, nil
|
|
161
|
-
|
|
162
|
-
h.ipaddr = URLCanonicalize::Destination.resolve(uri, options)
|
|
163
|
-
configure_timeouts(h)
|
|
164
|
-
ssl!(h)
|
|
165
|
-
|
|
166
|
-
h
|
|
167
|
-
end
|
|
168
|
-
|
|
169
181
|
def read_response_body(http_request, response)
|
|
170
182
|
if buffer_response_body?(http_request, response)
|
|
171
183
|
enforce_content_length!(response)
|
|
@@ -185,21 +197,5 @@ module URLCanonicalize
|
|
|
185
197
|
raise URLCanonicalize::Exception::ResponseTooLarge,
|
|
186
198
|
"Response body exceeds #{options[:max_body_bytes]} bytes"
|
|
187
199
|
end
|
|
188
|
-
|
|
189
|
-
def configure_timeouts(http)
|
|
190
|
-
http.open_timeout = options[:open_timeout]
|
|
191
|
-
http.read_timeout = options[:read_timeout]
|
|
192
|
-
http.write_timeout = options[:write_timeout]
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
def ssl!(http)
|
|
196
|
-
if uri.scheme == 'https'
|
|
197
|
-
http.use_ssl = true # Can generate exception
|
|
198
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
199
|
-
http.verify_hostname = true
|
|
200
|
-
else
|
|
201
|
-
http.use_ssl = false
|
|
202
|
-
end
|
|
203
|
-
end
|
|
204
200
|
end
|
|
205
201
|
end
|
|
@@ -6,12 +6,20 @@ module URLCanonicalize
|
|
|
6
6
|
DEFAULTS = {
|
|
7
7
|
allow_private_networks: false,
|
|
8
8
|
allowed_ports: [80, 443].freeze,
|
|
9
|
+
headers: {
|
|
10
|
+
'Accept-Language' => 'en-US,en;q=0.8',
|
|
11
|
+
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; WOW64) ' \
|
|
12
|
+
'AppleWebKit/537.36 (KHTML, like Gecko) ' \
|
|
13
|
+
'Chrome/51.0.2704.103 Safari/537.36'
|
|
14
|
+
}.freeze,
|
|
15
|
+
logger: nil,
|
|
9
16
|
max_body_bytes: 1_048_576,
|
|
10
17
|
total_timeout: 30,
|
|
11
18
|
open_timeout: 8,
|
|
12
19
|
read_timeout: 15,
|
|
13
20
|
write_timeout: 8,
|
|
14
|
-
max_redirects: 10
|
|
21
|
+
max_redirects: 10,
|
|
22
|
+
transport: Transport.new
|
|
15
23
|
}.freeze
|
|
16
24
|
|
|
17
25
|
POSITIVE_NUMERIC_OPTIONS = %i[total_timeout open_timeout read_timeout write_timeout].freeze
|
|
@@ -23,6 +31,7 @@ module URLCanonicalize
|
|
|
23
31
|
@values = DEFAULTS.merge(values)
|
|
24
32
|
validate!
|
|
25
33
|
@values[:allowed_ports] = @values[:allowed_ports].dup.freeze
|
|
34
|
+
@values[:headers] = @values[:headers].dup.freeze
|
|
26
35
|
@values.freeze
|
|
27
36
|
freeze
|
|
28
37
|
end
|
|
@@ -32,7 +41,7 @@ module URLCanonicalize
|
|
|
32
41
|
end
|
|
33
42
|
|
|
34
43
|
def to_h
|
|
35
|
-
@values.merge(allowed_ports: @values[:allowed_ports].dup)
|
|
44
|
+
@values.merge(allowed_ports: @values[:allowed_ports].dup, headers: @values[:headers].dup)
|
|
36
45
|
end
|
|
37
46
|
|
|
38
47
|
private
|
|
@@ -41,6 +50,9 @@ module URLCanonicalize
|
|
|
41
50
|
validate_private_networks!
|
|
42
51
|
validate_ports!
|
|
43
52
|
validate_positive_values!
|
|
53
|
+
validate_headers!
|
|
54
|
+
validate_logger!
|
|
55
|
+
validate_transport!
|
|
44
56
|
end
|
|
45
57
|
|
|
46
58
|
def validate_known_options!(values)
|
|
@@ -75,5 +87,26 @@ module URLCanonicalize
|
|
|
75
87
|
raise ArgumentError, "#{name} must be positive" unless value.is_a?(Integer) && value.positive?
|
|
76
88
|
end
|
|
77
89
|
end
|
|
90
|
+
|
|
91
|
+
def validate_headers!
|
|
92
|
+
headers = @values[:headers]
|
|
93
|
+
valid = headers.is_a?(Hash) && headers.all? { |key, value| key.is_a?(String) && value.is_a?(String) }
|
|
94
|
+
return if valid
|
|
95
|
+
|
|
96
|
+
raise ArgumentError, 'headers must be a Hash of String keys and String values'
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_logger!
|
|
100
|
+
logger = @values[:logger]
|
|
101
|
+
return if logger.nil? || logger.respond_to?(:debug)
|
|
102
|
+
|
|
103
|
+
raise ArgumentError, 'logger must respond to debug'
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_transport!
|
|
107
|
+
return if @values[:transport].respond_to?(:call)
|
|
108
|
+
|
|
109
|
+
raise ArgumentError, 'transport must respond to call'
|
|
110
|
+
end
|
|
78
111
|
end
|
|
79
112
|
end
|
|
@@ -85,7 +85,7 @@ module URLCanonicalize
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
def handle_success
|
|
88
|
-
@canonical_url = relative_to_absolute(URLCanonicalize::LinkHeader.canonical(response))
|
|
88
|
+
@canonical_url = normalized(relative_to_absolute(URLCanonicalize::LinkHeader.canonical(response)))
|
|
89
89
|
|
|
90
90
|
return enhanced_response if canonical_url || http_method == :get
|
|
91
91
|
|
|
@@ -122,7 +122,7 @@ module URLCanonicalize
|
|
|
122
122
|
|
|
123
123
|
def enhanced_response
|
|
124
124
|
if canonical_url
|
|
125
|
-
|
|
125
|
+
logger&.debug { "canonical_url: #{canonical_url}" }
|
|
126
126
|
response_plus = URLCanonicalize::Response::Success.new(canonical_url, response, html)
|
|
127
127
|
URLCanonicalize::Response::CanonicalFound.new(canonical_url, response_plus)
|
|
128
128
|
else
|
|
@@ -135,7 +135,13 @@ module URLCanonicalize
|
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
def canonical_url
|
|
138
|
-
@canonical_url ||= relative_to_absolute(canonical_url_element&.[]('href'), document_base_url)
|
|
138
|
+
@canonical_url ||= normalized(relative_to_absolute(canonical_url_element&.[]('href'), document_base_url))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Declared canonical URLs are returned to callers, so they get the same
|
|
142
|
+
# syntactic normalization as every requested URL
|
|
143
|
+
def normalized(url)
|
|
144
|
+
URLCanonicalize::URI.normalize(url) if url
|
|
139
145
|
end
|
|
140
146
|
|
|
141
147
|
# The first HTML link element whose rel tokens include "canonical", however
|
|
@@ -179,12 +185,7 @@ module URLCanonicalize
|
|
|
179
185
|
end
|
|
180
186
|
|
|
181
187
|
def headers
|
|
182
|
-
|
|
183
|
-
'Accept-Language' => 'en-US,en;q=0.8',
|
|
184
|
-
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; WOW64) ' \
|
|
185
|
-
'AppleWebKit/537.36 (KHTML, like Gecko) ' \
|
|
186
|
-
'Chrome/51.0.2704.103 Safari/537.36'
|
|
187
|
-
}
|
|
188
|
+
http.options[:headers]
|
|
188
189
|
end
|
|
189
190
|
|
|
190
191
|
def http_method=(value)
|
|
@@ -210,14 +211,11 @@ module URLCanonicalize
|
|
|
210
211
|
end
|
|
211
212
|
|
|
212
213
|
def log_response
|
|
213
|
-
debug
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
puts "#{http_method.upcase} #{url} #{response.code} #{response.message}"
|
|
217
|
-
|
|
218
|
-
return unless debug.casecmp?('headers')
|
|
214
|
+
logger&.debug { "#{http_method.upcase} #{url} #{response.code} #{response.message}" }
|
|
215
|
+
end
|
|
219
216
|
|
|
220
|
-
|
|
217
|
+
def logger
|
|
218
|
+
http.options[:logger]
|
|
221
219
|
end
|
|
222
220
|
end
|
|
223
221
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module URLCanonicalize
|
|
4
|
+
# The outcome of one canonicalization: the canonical URL, the response
|
|
5
|
+
# that confirmed it, and the chain of hops that led there. Immutable
|
|
6
|
+
class Result
|
|
7
|
+
# One step in the request chain: the URL that was requested and how it
|
|
8
|
+
# was discovered (:initial, :redirect or :canonical_link)
|
|
9
|
+
Hop = Struct.new(:url, :via, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
attr_reader :url, :response, :html, :chain, :source
|
|
12
|
+
|
|
13
|
+
def xml
|
|
14
|
+
Nokogiri::XML(response.body)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def initialize(url:, response:, html:, chain:, source:)
|
|
20
|
+
@url = url
|
|
21
|
+
@response = response
|
|
22
|
+
@html = html
|
|
23
|
+
@chain = chain.map(&:freeze).freeze
|
|
24
|
+
@source = source
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module URLCanonicalize
|
|
4
|
+
# Builds the connection for a destination, resolving the host through the
|
|
5
|
+
# fetch security policy. Injectable via the :transport option: anything
|
|
6
|
+
# responding to call(uri, options) and returning a Net::HTTP-compatible
|
|
7
|
+
# connection can replace it
|
|
8
|
+
class Transport
|
|
9
|
+
# Resolved lazily so the destination policy can be stubbed in tests
|
|
10
|
+
DEFAULT_RESOLVER = lambda do |uri, options|
|
|
11
|
+
URLCanonicalize::Destination.resolve(uri, options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(resolver: DEFAULT_RESOLVER)
|
|
15
|
+
@resolver = resolver
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(uri, options)
|
|
20
|
+
http = Net::HTTP.new(uri.host, uri.port, nil)
|
|
21
|
+
|
|
22
|
+
http.ipaddr = @resolver.call(uri, options)
|
|
23
|
+
configure_timeouts(http, options)
|
|
24
|
+
configure_tls(http, uri)
|
|
25
|
+
|
|
26
|
+
http
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def configure_timeouts(http, options)
|
|
32
|
+
http.open_timeout = options[:open_timeout]
|
|
33
|
+
http.read_timeout = options[:read_timeout]
|
|
34
|
+
http.write_timeout = options[:write_timeout]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def configure_tls(http, uri)
|
|
38
|
+
if uri.scheme == 'https'
|
|
39
|
+
http.use_ssl = true # Can generate exception
|
|
40
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
41
|
+
http.verify_hostname = true
|
|
42
|
+
else
|
|
43
|
+
http.use_ssl = false
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/url_canonicalize/uri.rb
CHANGED
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module URLCanonicalize
|
|
4
|
-
#
|
|
4
|
+
# Parses and normalizes URLs with local exception handling
|
|
5
5
|
class URI
|
|
6
6
|
class << self
|
|
7
7
|
VALID_CLASSES = [::URI::HTTP, ::URI::HTTPS].freeze
|
|
8
|
-
|
|
8
|
+
|
|
9
|
+
# A scheme is recognized only as a leading scheme: token; a colon
|
|
10
|
+
# anywhere else no longer suppresses the default scheme
|
|
11
|
+
SCHEME = /\A[A-Za-z][A-Za-z0-9+.-]*:/
|
|
9
12
|
|
|
10
13
|
def parse(url)
|
|
11
|
-
uri = ::URI.parse
|
|
14
|
+
uri = ::URI.parse normalize(url)
|
|
12
15
|
validate! uri
|
|
13
16
|
uri
|
|
14
17
|
rescue ::URI::InvalidURIError => e
|
|
15
18
|
raise URLCanonicalize::Exception::URI, "#{e.class}: #{e.message}" # the original error becomes the cause
|
|
16
19
|
end
|
|
17
20
|
|
|
21
|
+
# Syntactic normalization per RFC 3986, via Addressable: lowercase
|
|
22
|
+
# scheme and host, internationalized hosts to punycode, uppercase
|
|
23
|
+
# percent-encodings with unreserved characters decoded, dot segments
|
|
24
|
+
# resolved, default ports removed and the empty path replaced by "/".
|
|
25
|
+
# Fragments are client-side state, so no canonical URL carries one
|
|
26
|
+
def normalize(url)
|
|
27
|
+
addressable = Addressable::URI.parse(decorate(url.to_s.strip)).normalize
|
|
28
|
+
addressable.fragment = nil
|
|
29
|
+
addressable.to_s
|
|
30
|
+
rescue Addressable::URI::InvalidURIError, ArgumentError, Encoding::CompatibilityError => e
|
|
31
|
+
raise URLCanonicalize::Exception::URI, "#{e.class}: #{e.message}" # the original error becomes the cause
|
|
32
|
+
end
|
|
33
|
+
|
|
18
34
|
private
|
|
19
35
|
|
|
20
36
|
def validate!(uri)
|
|
@@ -24,7 +40,7 @@ module URLCanonicalize
|
|
|
24
40
|
end
|
|
25
41
|
|
|
26
42
|
def decorate(url)
|
|
27
|
-
return url if url.
|
|
43
|
+
return url if url.match?(SCHEME)
|
|
28
44
|
|
|
29
45
|
"http://#{url}" # Add protocol if we just receive a host name
|
|
30
46
|
end
|
data/lib/url_canonicalize.rb
CHANGED
|
@@ -11,6 +11,7 @@ autoload :OpenSSL, 'openssl'
|
|
|
11
11
|
# Core methods
|
|
12
12
|
module URLCanonicalize
|
|
13
13
|
autoload :BoundedBody, 'url_canonicalize/bounded_body'
|
|
14
|
+
autoload :Client, 'url_canonicalize/client'
|
|
14
15
|
autoload :Destination, 'url_canonicalize/destination'
|
|
15
16
|
autoload :Exception, 'url_canonicalize/exception'
|
|
16
17
|
autoload :HTTP, 'url_canonicalize/http'
|
|
@@ -19,20 +20,18 @@ module URLCanonicalize
|
|
|
19
20
|
autoload :Options, 'url_canonicalize/options'
|
|
20
21
|
autoload :Request, 'url_canonicalize/request'
|
|
21
22
|
autoload :Response, 'url_canonicalize/response'
|
|
23
|
+
autoload :Result, 'url_canonicalize/result'
|
|
24
|
+
autoload :Transport, 'url_canonicalize/transport'
|
|
22
25
|
autoload :URI, 'url_canonicalize/uri'
|
|
23
26
|
autoload :VERSION, 'url_canonicalize/version'
|
|
24
27
|
|
|
25
28
|
class << self
|
|
26
|
-
def canonicalize(url, **
|
|
27
|
-
|
|
29
|
+
def canonicalize(url, **)
|
|
30
|
+
Client.new(**).canonicalize(url)
|
|
28
31
|
end
|
|
29
32
|
|
|
30
|
-
def fetch(url, **
|
|
31
|
-
|
|
33
|
+
def fetch(url, **)
|
|
34
|
+
Client.new(**).fetch(url)
|
|
32
35
|
end
|
|
33
36
|
end
|
|
34
37
|
end
|
|
35
|
-
|
|
36
|
-
require 'monkey_patches/uri'
|
|
37
|
-
require 'monkey_patches/string'
|
|
38
|
-
require 'monkey_patches/addressable/uri'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: url_canonicalize
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dominic Sayers
|
|
@@ -49,11 +49,13 @@ files:
|
|
|
49
49
|
- LICENSE
|
|
50
50
|
- README.md
|
|
51
51
|
- SECURITY.md
|
|
52
|
-
- lib/monkey_patches/addressable/uri.rb
|
|
53
|
-
- lib/monkey_patches/string.rb
|
|
54
|
-
- lib/monkey_patches/uri.rb
|
|
55
52
|
- lib/url_canonicalize.rb
|
|
56
53
|
- lib/url_canonicalize/bounded_body.rb
|
|
54
|
+
- lib/url_canonicalize/client.rb
|
|
55
|
+
- lib/url_canonicalize/core_ext.rb
|
|
56
|
+
- lib/url_canonicalize/core_ext/addressable.rb
|
|
57
|
+
- lib/url_canonicalize/core_ext/string.rb
|
|
58
|
+
- lib/url_canonicalize/core_ext/uri.rb
|
|
57
59
|
- lib/url_canonicalize/destination.rb
|
|
58
60
|
- lib/url_canonicalize/exception.rb
|
|
59
61
|
- lib/url_canonicalize/http.rb
|
|
@@ -62,6 +64,8 @@ files:
|
|
|
62
64
|
- lib/url_canonicalize/options.rb
|
|
63
65
|
- lib/url_canonicalize/request.rb
|
|
64
66
|
- lib/url_canonicalize/response.rb
|
|
67
|
+
- lib/url_canonicalize/result.rb
|
|
68
|
+
- lib/url_canonicalize/transport.rb
|
|
65
69
|
- lib/url_canonicalize/uri.rb
|
|
66
70
|
- lib/url_canonicalize/version.rb
|
|
67
71
|
homepage: https://github.com/dominicsayers/url_canonicalize
|
|
@@ -81,7 +85,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
81
85
|
requirements:
|
|
82
86
|
- - ">="
|
|
83
87
|
- !ruby/object:Gem::Version
|
|
84
|
-
version: 3.
|
|
88
|
+
version: 3.3.0
|
|
85
89
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
86
90
|
requirements:
|
|
87
91
|
- - ">="
|