-
Notifications
You must be signed in to change notification settings - Fork 2
Add Extended Twingly::URL hasher #166
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
3954b9c
Pin and update environment to production versions
Eric-Twingly dac116c
Add Twingly::URL::Extended with extended normalization and hashing
Eric-Twingly 09aa7f3
Align CI matrix and specs with the new Ruby floor
Eric-Twingly a6bf26f
Restructure specs to test each public method directly
Eric-Twingly 92de069
Version bump to 7.1.0
Eric-Twingly File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| 3.3.7 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,3 +3,4 @@ | |
| source 'https://rubygems.org/' | ||
|
|
||
| gemspec | ||
| gem 'public_suffix', '5.1.1' | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require "twingly/url" | ||
| require "twingly/url/hasher" | ||
|
|
||
| module Twingly | ||
| class URL | ||
| # The purpose of the twingly-url gems is to replicate the normalization happening in our legacy .NET code. | ||
| # As both twingly-url and the legacy .NET system are used in production, we can't change these things in the | ||
| # gem just yet, which is why this extension was created here. | ||
| class Extended < Twingly::URL | ||
| # Use the same expression as we use in Zambezi: https://github.com/twingly/zambezi/blob/d997871aea199256ddc56762c975b6e961d5a2d1/lib/post_document.rb#L12 | ||
| # This way the urlhashes here will be the same as the one we have in Elasticsearch | ||
| PROTOCOL_EXPRESSION = /\Ahttps?:/i | ||
|
|
||
| HashResult = Struct.new(:url, :normalized_url, :urlhash, :legacy_urlhash) | ||
|
|
||
| # These parameters are just used for tracking, and should therefore not be included in the normalized URL | ||
| # See https://en.wikipedia.org/wiki/UTM_parameters | ||
| BLACKLISTED_QUERY_PARAMETERS = %w[ | ||
| utm_source | ||
| utm_medium | ||
| utm_campaign | ||
| utm_term | ||
| utm_content | ||
| b_source | ||
| b_medium | ||
| b_campaign | ||
| session | ||
| PHPSESSID | ||
| cb | ||
| ].freeze | ||
|
|
||
| BLACKLISTED_MATRIX_PARAMETERS = %w[ | ||
| jsessionid | ||
| jsessionid_jboss | ||
| JSESSIONID_B2BCH | ||
| JSESSIONID_kookbnagWEB | ||
| ].freeze | ||
|
|
||
| # Precompiled regex matching any blacklisted matrix parameter | ||
| # Matches: ;paramname=value where value ends at ; / ? or # | ||
| MATRIX_PARAM_REGEX = %r{;(?:#{BLACKLISTED_MATRIX_PARAMETERS.map { |p| Regexp.escape(p) }.join('|')})=[^;/?#]*}i | ||
|
|
||
| # Taken from twingly-url, with the addition of normalizing the query and fragment components | ||
| # See https://github.com/twingly/twingly-url/blob/e20f5fce077d93e89ef8520961be453c90cfec8c/lib/twingly/url.rb#L185-L193 | ||
| def normalized # rubocop:disable Metrics/AbcSize | ||
| normalized_url = addressable_uri.dup | ||
|
|
||
| normalized_url.scheme = normalized_scheme | ||
| normalized_url.host = normalized_host | ||
| normalized_url.path = normalized_path | ||
| normalized_url.query_values = normalized_query | ||
| normalized_url.fragment = normalized_fragment | ||
|
|
||
| # This is a bit ugly, remove when Twingly::URL is updated to | ||
| # to handle this. | ||
|
Eric-Twingly marked this conversation as resolved.
|
||
| public_suffix_domain = get_public_suffix_domain(normalized_url.host) | ||
| self.class.send(:new, normalized_url, public_suffix_domain) | ||
| end | ||
|
|
||
| def original_url_without_blacklisted_parameters | ||
| url = addressable_uri.dup | ||
| url.path = without_blacklisted_matrix_parameters(addressable_uri.path) | ||
| url.query_values = without_blacklisted_query_parameters(addressable_uri.query_values) | ||
|
Eric-Twingly marked this conversation as resolved.
|
||
| url.to_s | ||
| end | ||
|
|
||
| def query_values | ||
| addressable_uri.query_values | ||
| end | ||
|
|
||
| def query | ||
| addressable_uri.query | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # copied the way public_suffix_domain is calculated | ||
| # from Twingly::URL to be able calculate it without calling the parse method. | ||
|
Eric-Twingly marked this conversation as resolved.
|
||
| def get_public_suffix_domain(host) | ||
| public_suffix_domain = PublicSuffix.parse(host, list: CUSTOM_PSL, default_rule: nil) | ||
| raise Twingly::URL::Error::ParseError if public_suffix_domain.nil? | ||
| raise Twingly::URL::Error::ParseError if public_suffix_domain.sld.nil? | ||
|
|
||
| public_suffix_domain | ||
| end | ||
|
|
||
| def normalized_path | ||
| addressable_uri.path = without_blacklisted_matrix_parameters(addressable_uri.path) | ||
|
|
||
| super | ||
| end | ||
|
Eric-Twingly marked this conversation as resolved.
|
||
|
|
||
| def normalized_query | ||
| without_blacklisted_query_parameters(addressable_uri.query_values) | ||
| end | ||
|
|
||
| def without_blacklisted_query_parameters(query_values) | ||
| return if query_values.nil? | ||
|
|
||
| values_without_blacklisted_params = query_values.except(*BLACKLISTED_QUERY_PARAMETERS) | ||
|
|
||
| return if values_without_blacklisted_params.empty? | ||
|
|
||
| values_without_blacklisted_params | ||
|
Eric-Twingly marked this conversation as resolved.
|
||
| end | ||
|
|
||
| def without_blacklisted_matrix_parameters(path) | ||
| # No need to run gsub if there are no matrix parameters | ||
| return path unless path.include?(";") | ||
|
|
||
| path.gsub(MATRIX_PARAM_REGEX, "") | ||
| end | ||
|
|
||
| def normalized_fragment | ||
| nil | ||
| end | ||
|
|
||
| def self.normalize_and_calculate_urlhash(url) | ||
| return empty_result if url.to_s.strip.empty? | ||
|
|
||
| twingly_url = if url.is_a?(Extended) | ||
| url | ||
| else | ||
| Extended.parse(url) | ||
| end | ||
|
|
||
| return empty_result unless twingly_url.valid? | ||
|
|
||
| original_url = twingly_url.original_url_without_blacklisted_parameters | ||
| normalized_url = twingly_url.normalized.to_s | ||
| normalized_url_without_scheme = remove_scheme(normalized_url) | ||
| urlhash = calculate_urlhash(normalized_url_without_scheme) | ||
| legacy_urlhash = calculate_urlhash(normalized_url) | ||
|
|
||
| HashResult.new(original_url, normalized_url_without_scheme, urlhash, legacy_urlhash) | ||
| end | ||
|
|
||
| def self.empty_result | ||
| HashResult.new(nil, nil, nil, nil) | ||
| end | ||
|
|
||
| def self.remove_scheme(url) | ||
| url.to_s.sub(PROTOCOL_EXPRESSION, "") | ||
| end | ||
|
|
||
| def self.calculate_urlhash(url) | ||
| Twingly::URL::Hasher.documentdb_hash(url).to_s | ||
| end | ||
|
|
||
| private_class_method :empty_result | ||
| private_class_method :calculate_urlhash | ||
| end | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,6 @@ | |
|
|
||
| module Twingly | ||
| class URL | ||
| VERSION = "7.0.1" | ||
| VERSION = "7.1.0" | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require "twingly/url/extended" | ||
|
|
||
| RSpec.describe Twingly::URL::Extended do | ||
| describe "#normalized" do | ||
| subject { described_class.parse(url).normalized.to_s } | ||
|
|
||
| context "removes both matrix and query parameters" do | ||
| let(:url) { "https://example.com/path;jsessionid=ABC.123?id=1&utm_source=google&name=test" } | ||
| let(:expected) { "https://www.example.com/path?id=1&name=test" } | ||
|
|
||
| it { is_expected.to eq(expected) } | ||
| end | ||
|
|
||
| context "applies parent path normalization after removing matrix parameters" do | ||
| let(:url) { "https://example.com/article/;jsessionid=XYZ" } | ||
| let(:expected) { "https://www.example.com/article" } | ||
|
|
||
| it { is_expected.to eq(expected) } | ||
| end | ||
|
|
||
| context "removes the fragment" do | ||
| let(:url) { "https://example.com/#foo" } | ||
| let(:expected) { "https://www.example.com/" } | ||
|
|
||
| it { is_expected.to eq(expected) } | ||
| end | ||
|
|
||
| context "removes the UTM parameters" do | ||
| let(:url) { "https://example.com/?baz=qux&#{utm_query_parameters}&foo=bar" } | ||
| let(:expected) { "https://www.example.com/?baz=qux&foo=bar" } | ||
|
|
||
| let(:utm_query_parameters) do | ||
| [ | ||
| "utm_source=foo", | ||
| "utm_medium=bar", | ||
| "utm_campaign=baz", | ||
| "utm_term=qux", | ||
| "utm_content=quux", | ||
| ].join("&") | ||
| end | ||
|
|
||
| it { is_expected.to eq(expected) } | ||
| end | ||
|
|
||
| context "removes both the UTM parameters and the ending '?' when the URL only contains UTM query parameters" do | ||
| let(:url) { "https://example.com/?utm_source=foo&utm_medium=bar" } | ||
| let(:expected) { "https://www.example.com/" } | ||
|
|
||
| it { is_expected.to eq(expected) } | ||
| end | ||
|
|
||
| context "removes both the UTM parameters and the fragment" do | ||
| let(:url) { "https://example.com/?baz=qux&utm_source=123&foo=bar#quux#something" } | ||
| let(:expected) { "https://www.example.com/?baz=qux&foo=bar" } | ||
|
|
||
| it { is_expected.to eq(expected) } | ||
| end | ||
|
|
||
| context "sorts the query parameters" do | ||
| let(:url) { "https://example.com/?foo=bar&baz=qux&asd=123" } | ||
| let(:expected) { "https://www.example.com/?asd=123&baz=qux&foo=bar" } | ||
|
|
||
| it { is_expected.to eq(expected) } | ||
| end | ||
|
|
||
| context "returns a normalized URL without query parameters" do | ||
| let(:url) { "https://example.com/blog" } | ||
| let(:expected) { "https://www.example.com/blog" } | ||
|
|
||
| it { is_expected.to eq(expected) } | ||
| end | ||
|
|
||
| context "keeps the embedded URL percent-encoded in the query" do | ||
| let(:url) { "https://example.com/login?next=https://example.com/account" } | ||
| let(:expected) { "https://www.example.com/login?next=https%3A%2F%2Fexample.com%2Faccount" } | ||
|
|
||
| it { is_expected.to eq(expected) } | ||
| end | ||
| end | ||
|
|
||
| describe "#original_url_without_blacklisted_parameters" do | ||
| subject { described_class.parse(url).original_url_without_blacklisted_parameters } | ||
|
|
||
| context "removes the UTM parameters" do | ||
| let(:url) { "https://example.com/?baz=qux&#{utm_query_parameters}&foo=bar" } | ||
| let(:expected) { "https://example.com/?baz=qux&foo=bar" } | ||
|
|
||
| let(:utm_query_parameters) do | ||
| [ | ||
| "utm_source=foo", | ||
| "utm_medium=bar", | ||
| "utm_campaign=baz", | ||
| "utm_term=qux", | ||
| "utm_content=quux", | ||
| ].join("&") | ||
| end | ||
|
|
||
| it { is_expected.to eq(expected) } | ||
| end | ||
|
|
||
| context "removes both the UTM parameters and the ending '?' when the URL only contains UTM query parameters" do | ||
| let(:url) { "https://example.com/?utm_source=foo&utm_medium=bar" } | ||
| let(:expected) { "https://example.com/" } | ||
|
|
||
| it { is_expected.to eq(expected) } | ||
| end | ||
| end | ||
|
|
||
| describe ".normalize_and_calculate_urlhash" do | ||
| let(:url) { "https://example.com" } | ||
|
|
||
| it "produces same hash when blacklisted query parameters differ" do | ||
| url_with = "https://example.com/page?id=1&session=x&PHPSESSID=y&utm_source=z&cb=w" | ||
| url_without = "https://example.com/page?id=1" | ||
|
|
||
| result_with = described_class.normalize_and_calculate_urlhash(url_with) | ||
| result_without = described_class.normalize_and_calculate_urlhash(url_without) | ||
|
|
||
| expect(result_with.urlhash).to eq(result_without.urlhash) | ||
| expect(result_with.legacy_urlhash).to eq(result_without.legacy_urlhash) | ||
| end | ||
|
|
||
| it "produces same hash when blacklisted matrix parameters differ" do | ||
| url_with = "https://example.com/path;jsessionid=ABC.123;allowed_matrix_param=yes;JSESSIONID_B2BCH=XYZ" | ||
| url_without = "https://example.com/path;allowed_matrix_param=yes" | ||
|
|
||
| result_with = described_class.normalize_and_calculate_urlhash(url_with) | ||
| result_without = described_class.normalize_and_calculate_urlhash(url_without) | ||
|
|
||
| expect(result_with.urlhash).to eq(result_without.urlhash) | ||
| expect(result_with.legacy_urlhash).to eq(result_without.legacy_urlhash) | ||
| end | ||
|
|
||
| it "returns both original url, normalized url, urlhash and legacy_urlhash" do | ||
| expect(described_class.normalize_and_calculate_urlhash(url)).to have_attributes(url: url, | ||
| normalized_url: "//www.example.com/", | ||
| urlhash: "1119909257551956256", | ||
| legacy_urlhash: "14653629529287702089") | ||
| end | ||
|
|
||
| it "calculates the legacy urlhash from the normalized URL with its scheme kept" do | ||
| result = described_class.normalize_and_calculate_urlhash("https://example.com/blog") | ||
|
|
||
| expect(result.legacy_urlhash) | ||
| .to eq(Twingly::URL::Hasher.documentdb_hash("https:#{result.normalized_url}").to_s) | ||
| end | ||
|
|
||
| ["", nil].each do |empty_value| | ||
| context "when url is #{empty_value.inspect}" do | ||
| let(:url) { empty_value } | ||
|
|
||
| it "returns a result where all attributes are set to nil" do | ||
| expect(described_class.normalize_and_calculate_urlhash(url)).to have_attributes(url: nil, | ||
| normalized_url: nil, | ||
| urlhash: nil, | ||
| legacy_urlhash: nil) | ||
| end | ||
| end | ||
| end | ||
|
|
||
| context "with an invalid URL" do | ||
| let(:url) { "http:// example.com? hello # there" } | ||
|
|
||
| it "returns a result where all attributes are set to nil" do | ||
| expect(described_class.normalize_and_calculate_urlhash(url)).to have_attributes(url: nil, | ||
| normalized_url: nil, | ||
| urlhash: nil, | ||
| legacy_urlhash: nil) | ||
| end | ||
| end | ||
| end | ||
|
|
||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.