brand_logo 1.0.0 → 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 -0
- data/README.md +87 -50
- data/lib/brand_logo/cache.rb +48 -0
- data/lib/brand_logo/concurrency.rb +53 -0
- data/lib/brand_logo/config.rb +95 -43
- data/lib/brand_logo/context.rb +68 -0
- data/lib/brand_logo/deadline.rb +30 -0
- data/lib/brand_logo/dimensions.rb +79 -0
- data/lib/brand_logo/domain.rb +64 -0
- data/lib/brand_logo/errors.rb +1 -3
- data/lib/brand_logo/fetcher.rb +78 -81
- data/lib/brand_logo/http/client.rb +25 -0
- data/lib/brand_logo/http/real_client.rb +110 -0
- data/lib/brand_logo/http/response.rb +8 -0
- data/lib/brand_logo/http/url_guard.rb +68 -0
- data/lib/brand_logo/icon.rb +41 -20
- data/lib/brand_logo/image_format.rb +53 -0
- data/lib/brand_logo/image_probe.rb +29 -0
- data/lib/brand_logo/logging.rb +1 -7
- data/lib/brand_logo/lookup.rb +94 -0
- data/lib/brand_logo/page.rb +37 -0
- data/lib/brand_logo/ranker.rb +115 -0
- data/lib/brand_logo/sorbet.rb +8 -0
- data/lib/brand_logo/sorbet_shim.rb +44 -0
- data/lib/brand_logo/strategies/base.rb +19 -0
- data/lib/brand_logo/strategies/browserconfig.rb +70 -0
- data/lib/brand_logo/strategies/duckduckgo.rb +21 -0
- data/lib/brand_logo/strategies/google.rb +29 -0
- data/lib/brand_logo/strategies/json_ld.rb +91 -0
- data/lib/brand_logo/strategies/link_tag.rb +58 -0
- data/lib/brand_logo/strategies/manifest.rb +68 -0
- data/lib/brand_logo/strategies/meta_tag.rb +80 -0
- data/lib/brand_logo/url_resolver.rb +30 -0
- data/lib/brand_logo/version.rb +1 -1
- data/lib/brand_logo.rb +30 -28
- metadata +42 -30
- data/lib/brand_logo/html_parser.rb +0 -63
- data/lib/brand_logo/http_client.rb +0 -59
- data/lib/brand_logo/image_analyzer.rb +0 -36
- data/lib/brand_logo/strategies/base_strategy.rb +0 -117
- data/lib/brand_logo/strategies/duckduckgo_strategy.rb +0 -45
- data/lib/brand_logo/strategies/manifest_strategy.rb +0 -137
- data/lib/brand_logo/strategies/meta_tag_strategy.rb +0 -95
- data/lib/brand_logo/strategies/scraping/default_favicon_checker.rb +0 -33
- data/lib/brand_logo/strategies/scraping/dimensions_extractor.rb +0 -42
- data/lib/brand_logo/strategies/scraping/format_extractor.rb +0 -56
- data/lib/brand_logo/strategies/scraping/icon_finder.rb +0 -79
- data/lib/brand_logo/strategies/scraping/url_normalizer.rb +0 -50
- data/lib/brand_logo/strategies/scraping_strategy.rb +0 -77
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module BrandLogo
|
|
5
|
+
Dimensions = Data.define(:width, :height) do
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { params(width: T.nilable(Integer), height: T.nilable(Integer)).void }
|
|
9
|
+
def initialize(width: nil, height: nil)
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
sig { params(value: T.untyped).returns(Dimensions) }
|
|
14
|
+
def self.parse(value)
|
|
15
|
+
return new unless value.is_a?(String)
|
|
16
|
+
|
|
17
|
+
sizes = value.split.filter_map do |token|
|
|
18
|
+
match = /\A(\d+)x(\d+)\z/i.match(token)
|
|
19
|
+
new(width: match[1].to_i, height: match[2].to_i) if match
|
|
20
|
+
end
|
|
21
|
+
sizes.select(&:known?).max_by(&:area) || new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
sig { params(value: T.untyped).returns(T.nilable(Dimensions)) }
|
|
25
|
+
def self.coerce(value)
|
|
26
|
+
case value
|
|
27
|
+
when nil, Dimensions then value
|
|
28
|
+
when Integer then new(width: value, height: value)
|
|
29
|
+
when ::Hash then new(width: value.fetch(:width), height: value.fetch(:height))
|
|
30
|
+
else raise ValidationError, "Invalid dimensions: #{value.inspect}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { returns(T.nilable([Integer, Integer])) }
|
|
35
|
+
def to_pair
|
|
36
|
+
w = width
|
|
37
|
+
h = height
|
|
38
|
+
[w, h] if w&.positive? && h&.positive?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { returns(T::Boolean) }
|
|
42
|
+
def known?
|
|
43
|
+
!to_pair.nil?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { returns(Integer) }
|
|
47
|
+
def area
|
|
48
|
+
w, h = to_pair
|
|
49
|
+
w && h ? w * h : 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
sig { returns(Integer) }
|
|
53
|
+
def side
|
|
54
|
+
to_pair&.min || 0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
sig { params(other: Dimensions).returns(T::Boolean) }
|
|
58
|
+
def at_least?(other)
|
|
59
|
+
compare(other) { |mine, theirs| mine >= theirs }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
sig { params(other: Dimensions).returns(T::Boolean) }
|
|
63
|
+
def at_most?(other)
|
|
64
|
+
compare(other) { |mine, theirs| mine <= theirs }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Unknown dimensions on either side never fail a comparison.
|
|
70
|
+
sig { params(other: Dimensions, block: T.proc.params(mine: Integer, theirs: Integer).returns(T::Boolean)).returns(T::Boolean) }
|
|
71
|
+
def compare(other, &block)
|
|
72
|
+
mine = to_pair
|
|
73
|
+
theirs = other.to_pair
|
|
74
|
+
return true unless mine && theirs
|
|
75
|
+
|
|
76
|
+
mine.zip(theirs).all? { |m, t| yield(m, T.must(t)) }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module BrandLogo
|
|
7
|
+
module Domain
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
MAX_LENGTH = 253
|
|
11
|
+
LABEL = T.let(/\A[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\z/, Regexp)
|
|
12
|
+
TLD = T.let(/\A([a-z]{2,}|xn--[a-z0-9-]+)\z/, Regexp)
|
|
13
|
+
IPV4 = T.let(/\A\d+(\.\d+){3}\z/, Regexp)
|
|
14
|
+
|
|
15
|
+
sig { params(input: String).returns(String) }
|
|
16
|
+
def self.normalize(input)
|
|
17
|
+
text = input.strip.downcase
|
|
18
|
+
invalid!(input, 'is empty') if text.empty?
|
|
19
|
+
invalid!(input, 'must be ASCII; use punycode ("xn--...") for internationalized names') unless text.ascii_only?
|
|
20
|
+
|
|
21
|
+
host = extract_host(input, text)
|
|
22
|
+
validate!(input, host)
|
|
23
|
+
host
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sig { params(input: String, text: String).returns(String) }
|
|
27
|
+
def self.extract_host(input, text)
|
|
28
|
+
host = if text.include?('://') || text.start_with?('//')
|
|
29
|
+
URI.parse(text).host.to_s
|
|
30
|
+
else
|
|
31
|
+
text.split(%r{[/?#]}, 2).first.to_s
|
|
32
|
+
end
|
|
33
|
+
invalid!(input, 'is an IP address') if host.start_with?('[') || host.count(':') > 1
|
|
34
|
+
host.sub(/:\d*\z/, '').delete_suffix('.')
|
|
35
|
+
rescue URI::InvalidURIError
|
|
36
|
+
invalid!(input, 'is not a valid URL')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
sig { params(input: String, host: String).void }
|
|
40
|
+
def self.validate!(input, host)
|
|
41
|
+
invalid!(input, 'has no hostname') if host.empty?
|
|
42
|
+
invalid!(input, 'is an IP address') if IPV4.match?(host)
|
|
43
|
+
invalid!(input, "is longer than #{MAX_LENGTH} characters") if host.length > MAX_LENGTH
|
|
44
|
+
|
|
45
|
+
validate_labels!(input, host.split('.', -1))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
sig { params(input: String, labels: T::Array[String]).void }
|
|
49
|
+
def self.validate_labels!(input, labels)
|
|
50
|
+
invalid!(input, 'must contain a dot (e.g. "example.com")') if labels.length < 2
|
|
51
|
+
unless labels.all? { |label| LABEL.match?(label) }
|
|
52
|
+
invalid!(input, 'has an invalid label (1-63 letters, digits or inner hyphens)')
|
|
53
|
+
end
|
|
54
|
+
invalid!(input, 'has an invalid top-level domain') unless TLD.match?(labels.last.to_s)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
sig { params(input: String, reason: String).returns(T.noreturn) }
|
|
58
|
+
def self.invalid!(input, reason)
|
|
59
|
+
raise ValidationError, "Invalid domain #{input.inspect}: #{reason}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private_class_method :extract_host, :validate!, :validate_labels!, :invalid!
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/brand_logo/errors.rb
CHANGED
data/lib/brand_logo/fetcher.rb
CHANGED
|
@@ -1,109 +1,106 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
# typed: strict
|
|
3
3
|
|
|
4
|
-
require 'sorbet-runtime'
|
|
5
|
-
|
|
6
4
|
module BrandLogo
|
|
7
|
-
# Entry point for brand_logo retrieval.
|
|
8
|
-
#
|
|
9
|
-
# Composes a chain of strategies tried in order until one finds a valid icon.
|
|
10
|
-
# Dependencies (HTTP client, HTML parser, image analyzer) are instantiated once
|
|
11
|
-
# and shared across all strategies.
|
|
12
|
-
#
|
|
13
|
-
# Usage:
|
|
14
|
-
# # Default configuration
|
|
15
|
-
# icon = BrandLogo::Fetcher.new.fetch('github.com')
|
|
16
|
-
#
|
|
17
|
-
# # Custom config
|
|
18
|
-
# config = BrandLogo::Config.new(min_dimensions: { width: 32, height: 32 }, timeout: 5)
|
|
19
|
-
# icon = BrandLogo::Fetcher.new(config: config).fetch('github.com')
|
|
20
|
-
#
|
|
21
|
-
# # Custom strategy chain (OCP)
|
|
22
|
-
# fetcher = BrandLogo::Fetcher.new(strategies: [MyCustomStrategy.new(config: config)])
|
|
23
|
-
#
|
|
24
|
-
# # All icons from all strategies
|
|
25
|
-
# icons = BrandLogo::Fetcher.new.fetch_all('github.com')
|
|
26
5
|
class Fetcher
|
|
27
6
|
extend T::Sig
|
|
28
7
|
|
|
29
|
-
|
|
8
|
+
CACHE_NAMESPACE = 'brand_logo/v2'
|
|
9
|
+
|
|
10
|
+
sig { returns(Config) }
|
|
11
|
+
attr_reader :config
|
|
30
12
|
|
|
31
13
|
sig do
|
|
32
14
|
params(
|
|
33
|
-
config:
|
|
34
|
-
|
|
15
|
+
config: Config,
|
|
16
|
+
http: T.nilable(Http::Client),
|
|
17
|
+
strategies: T.nilable(T::Array[Strategies::Base])
|
|
35
18
|
).void
|
|
36
19
|
end
|
|
37
|
-
def initialize(config: nil, strategies: nil)
|
|
38
|
-
@config
|
|
39
|
-
@
|
|
40
|
-
@
|
|
41
|
-
@
|
|
42
|
-
@strategies
|
|
20
|
+
def initialize(config: Config.new, http: nil, strategies: nil)
|
|
21
|
+
@config = config
|
|
22
|
+
@http = T.let(http || Http::RealClient.new(config), Http::Client)
|
|
23
|
+
@strategies = T.let(strategies || default_strategies, T::Array[Strategies::Base])
|
|
24
|
+
@ranker = T.let(Ranker.new(config), Ranker)
|
|
25
|
+
strategy_names = @strategies.map { |strategy| T.cast(strategy, Object).class.name }
|
|
26
|
+
@cache_digest = T.let(Digest::SHA256.hexdigest([config.cache_digest, strategy_names].inspect)[0, 12].to_s, String)
|
|
43
27
|
end
|
|
44
28
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
validate_domain!(domain)
|
|
50
|
-
BrandLogo::Logging.logger.debug("Fetching brand_logo for: #{domain}")
|
|
51
|
-
|
|
52
|
-
@strategies.each do |strategy|
|
|
53
|
-
BrandLogo::Logging.logger.debug("Trying #{strategy.class.name}")
|
|
54
|
-
icon = strategy.fetch(domain)
|
|
55
|
-
return icon if icon
|
|
56
|
-
end
|
|
29
|
+
sig { params(input: String).returns(T.nilable(Icon)) }
|
|
30
|
+
def fetch(input)
|
|
31
|
+
fetch_domain(Domain.normalize(input))
|
|
32
|
+
end
|
|
57
33
|
|
|
58
|
-
|
|
34
|
+
sig { params(input: String).returns(Icon) }
|
|
35
|
+
def fetch!(input)
|
|
36
|
+
fetch(input) || raise(NoIconFoundError, "No icon found for #{input.inspect}")
|
|
59
37
|
end
|
|
60
38
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
39
|
+
sig { params(input: String).returns(T::Array[Icon]) }
|
|
40
|
+
def fetch_all(input)
|
|
41
|
+
domain = Domain.normalize(input)
|
|
42
|
+
stored = cached("all/#{domain}") do
|
|
43
|
+
lookup = new_lookup(domain)
|
|
44
|
+
icons = lookup.all
|
|
45
|
+
[icons.map(&:to_h), ttl_for(found: !icons.empty?, lookup: lookup)]
|
|
46
|
+
end
|
|
47
|
+
Array(stored).map { |hash| Icon.from_h(hash) }
|
|
48
|
+
end
|
|
65
49
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
50
|
+
sig { params(inputs: T::Array[String]).returns(T::Hash[String, T.nilable(Icon)]) }
|
|
51
|
+
def fetch_many(inputs)
|
|
52
|
+
domains = inputs.to_h { |input| [input, Domain.normalize(input)] }
|
|
53
|
+
unique = domains.values.uniq
|
|
54
|
+
icons = unique.zip(Concurrency.map(unique, size: @config.concurrency) { |domain| fetch_domain(domain) }).to_h
|
|
55
|
+
domains.transform_values { |domain| icons[domain] }
|
|
69
56
|
end
|
|
70
57
|
|
|
71
58
|
private
|
|
72
59
|
|
|
73
|
-
sig { params(domain: String).
|
|
74
|
-
def
|
|
75
|
-
|
|
60
|
+
sig { params(domain: String).returns(T.nilable(Icon)) }
|
|
61
|
+
def fetch_domain(domain)
|
|
62
|
+
stored = cached("best/#{domain}") do
|
|
63
|
+
lookup = new_lookup(domain)
|
|
64
|
+
icon = lookup.best
|
|
65
|
+
[icon ? icon.to_h : false, ttl_for(found: !icon.nil?, lookup: lookup)]
|
|
66
|
+
end
|
|
67
|
+
stored ? Icon.from_h(stored) : nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sig { params(key: String, block: T.proc.returns([T.untyped, T.nilable(Integer)])).returns(T.untyped) }
|
|
71
|
+
def cached(key, &block)
|
|
72
|
+
cache = @config.cache
|
|
73
|
+
return yield.first unless cache
|
|
74
|
+
|
|
75
|
+
full_key = "#{CACHE_NAMESPACE}/#{key}/#{@cache_digest}"
|
|
76
|
+
stored = cache.read(full_key)
|
|
77
|
+
return stored unless stored.nil?
|
|
78
|
+
|
|
79
|
+
value, ttl = yield
|
|
80
|
+
cache.write(full_key, value, expires_in: ttl) if ttl&.positive?
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A lookup cut short by the deadline says little about the site, so it is never cached.
|
|
85
|
+
sig { params(found: T::Boolean, lookup: Lookup).returns(T.nilable(Integer)) }
|
|
86
|
+
def ttl_for(found:, lookup:)
|
|
87
|
+
return nil if lookup.timed_out?
|
|
88
|
+
|
|
89
|
+
found ? @config.cache_ttl : @config.negative_cache_ttl
|
|
90
|
+
end
|
|
76
91
|
|
|
77
|
-
|
|
92
|
+
sig { params(domain: String).returns(Lookup) }
|
|
93
|
+
def new_lookup(domain)
|
|
94
|
+
context = Context.new(domain: domain, config: @config, http: @http)
|
|
95
|
+
Lookup.new(context: context, strategies: @strategies, ranker: @ranker)
|
|
78
96
|
end
|
|
79
97
|
|
|
80
|
-
sig { returns(T::Array[Strategies::
|
|
81
|
-
def
|
|
82
|
-
[
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
html_parser: @html_parser,
|
|
87
|
-
image_analyzer: @image_analyzer
|
|
88
|
-
),
|
|
89
|
-
Strategies::MetaTagStrategy.new(
|
|
90
|
-
config: @config,
|
|
91
|
-
http_client: @http_client,
|
|
92
|
-
html_parser: @html_parser,
|
|
93
|
-
image_analyzer: @image_analyzer
|
|
94
|
-
),
|
|
95
|
-
Strategies::ManifestStrategy.new(
|
|
96
|
-
config: @config,
|
|
97
|
-
http_client: @http_client,
|
|
98
|
-
html_parser: @html_parser,
|
|
99
|
-
image_analyzer: @image_analyzer
|
|
100
|
-
),
|
|
101
|
-
Strategies::DuckduckgoStrategy.new(
|
|
102
|
-
config: @config,
|
|
103
|
-
http_client: @http_client,
|
|
104
|
-
image_analyzer: @image_analyzer
|
|
105
|
-
)
|
|
106
|
-
]
|
|
98
|
+
sig { returns(T::Array[Strategies::Base]) }
|
|
99
|
+
def default_strategies
|
|
100
|
+
document = [Strategies::LinkTag.new, Strategies::JsonLd.new, Strategies::MetaTag.new]
|
|
101
|
+
remote = [Strategies::Manifest.new, Strategies::Browserconfig.new]
|
|
102
|
+
external = { google: Strategies::Google, duckduckgo: Strategies::Duckduckgo }
|
|
103
|
+
document + remote + @config.external_fallbacks.map { |name| external.fetch(name).new }
|
|
107
104
|
end
|
|
108
105
|
end
|
|
109
106
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module BrandLogo
|
|
5
|
+
module Http
|
|
6
|
+
module Client
|
|
7
|
+
extend T::Sig
|
|
8
|
+
extend T::Helpers
|
|
9
|
+
|
|
10
|
+
interface!
|
|
11
|
+
|
|
12
|
+
# Never raises: any failure is nil, so strategies treat it as "no icon".
|
|
13
|
+
# `max_bytes` still caps the read because servers may ignore `range`.
|
|
14
|
+
sig do
|
|
15
|
+
abstract.params(
|
|
16
|
+
url: String,
|
|
17
|
+
deadline: Deadline,
|
|
18
|
+
max_bytes: Integer,
|
|
19
|
+
range: T.nilable(T::Range[Integer])
|
|
20
|
+
).returns(T.nilable(Response))
|
|
21
|
+
end
|
|
22
|
+
def get(url, deadline:, max_bytes:, range: nil); end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
require 'http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module BrandLogo
|
|
8
|
+
module Http
|
|
9
|
+
class RealClient
|
|
10
|
+
extend T::Sig
|
|
11
|
+
include Client
|
|
12
|
+
|
|
13
|
+
ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/*;q=0.8,*/*;q=0.5'
|
|
14
|
+
ACCEPT_LANGUAGE = 'en;q=0.8,*;q=0.5'
|
|
15
|
+
NETWORK_ERRORS = T.let([
|
|
16
|
+
HTTP::Error, OpenSSL::SSL::SSLError, SocketError, IOError, SystemCallError, Timeout::Error, URI::Error
|
|
17
|
+
].freeze, T::Array[T.class_of(Exception)])
|
|
18
|
+
REDIRECT_STATUSES = T.let([301, 302, 303, 307, 308].freeze, T::Array[Integer])
|
|
19
|
+
|
|
20
|
+
sig { params(config: Config).void }
|
|
21
|
+
def initialize(config)
|
|
22
|
+
@config = config
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
sig do
|
|
26
|
+
override.params(
|
|
27
|
+
url: String,
|
|
28
|
+
deadline: Deadline,
|
|
29
|
+
max_bytes: Integer,
|
|
30
|
+
range: T.nilable(T::Range[Integer])
|
|
31
|
+
).returns(T.nilable(Response))
|
|
32
|
+
end
|
|
33
|
+
def get(url, deadline:, max_bytes:, range: nil)
|
|
34
|
+
current = url
|
|
35
|
+
(@config.max_hops + 1).times do
|
|
36
|
+
return nil if deadline.expired? || !allowed?(current)
|
|
37
|
+
|
|
38
|
+
result = request(current, deadline, max_bytes, range)
|
|
39
|
+
return result if result.nil? || result.is_a?(Response)
|
|
40
|
+
|
|
41
|
+
current = result
|
|
42
|
+
end
|
|
43
|
+
log("too many redirects for #{url}")
|
|
44
|
+
nil
|
|
45
|
+
rescue *NETWORK_ERRORS => e
|
|
46
|
+
log("#{url}: #{e.class}: #{e.message}")
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
sig { params(url: String).returns(T::Boolean) }
|
|
53
|
+
def allowed?(url)
|
|
54
|
+
return true if UrlGuard.allowed?(url, block_private_ips: @config.block_private_ips)
|
|
55
|
+
|
|
56
|
+
log("blocked #{url}")
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
sig do
|
|
61
|
+
params(url: String, deadline: Deadline, max_bytes: Integer, range: T.nilable(T::Range[Integer]))
|
|
62
|
+
.returns(T.nilable(T.any(Response, String)))
|
|
63
|
+
end
|
|
64
|
+
def request(url, deadline, max_bytes, range)
|
|
65
|
+
client = HTTP.timeout([@config.timeout, deadline.remaining].min).headers(headers(range))
|
|
66
|
+
response = client.get(url)
|
|
67
|
+
status = response.status.to_i
|
|
68
|
+
return redirect_target(url, response) if REDIRECT_STATUSES.include?(status)
|
|
69
|
+
|
|
70
|
+
unless response.status.success?
|
|
71
|
+
log("#{url}: HTTP #{status}")
|
|
72
|
+
return nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Response.new(url: url, body: read_body(response, max_bytes))
|
|
76
|
+
ensure
|
|
77
|
+
client&.close
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
sig { params(url: String, response: HTTP::Response).returns(T.nilable(String)) }
|
|
81
|
+
def redirect_target(url, response)
|
|
82
|
+
location = response.headers['Location']&.to_s
|
|
83
|
+
return nil if location.nil? || location.empty?
|
|
84
|
+
|
|
85
|
+
URI.join(url, location).to_s
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
sig { params(response: HTTP::Response, max_bytes: Integer).returns(String) }
|
|
89
|
+
def read_body(response, max_bytes)
|
|
90
|
+
buffer = String.new(encoding: Encoding::BINARY)
|
|
91
|
+
response.body.each do |chunk|
|
|
92
|
+
buffer << chunk.b
|
|
93
|
+
break if buffer.bytesize >= max_bytes
|
|
94
|
+
end
|
|
95
|
+
buffer.bytesize > max_bytes ? T.must(buffer.byteslice(0, max_bytes)) : buffer
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
sig { params(range: T.nilable(T::Range[Integer])).returns(T::Hash[String, String]) }
|
|
99
|
+
def headers(range)
|
|
100
|
+
base = { 'User-Agent' => @config.user_agent, 'Accept' => ACCEPT, 'Accept-Language' => ACCEPT_LANGUAGE }
|
|
101
|
+
range ? base.merge('Range' => "bytes=#{range.min}-#{range.max}") : base
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
sig { params(message: String).void }
|
|
105
|
+
def log(message)
|
|
106
|
+
Logging.logger.debug("[BrandLogo] #{message}")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
require 'ipaddr'
|
|
5
|
+
require 'resolv'
|
|
6
|
+
require 'socket'
|
|
7
|
+
require 'uri'
|
|
8
|
+
|
|
9
|
+
module BrandLogo
|
|
10
|
+
module Http
|
|
11
|
+
module UrlGuard
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
SCHEMES = T.let(%w[http https].freeze, T::Array[String])
|
|
15
|
+
BLOCKED_RANGES = T.let(
|
|
16
|
+
%w[
|
|
17
|
+
0.0.0.0/8 100.64.0.0/10 192.0.0.0/24 192.0.2.0/24 198.18.0.0/15 198.51.100.0/24
|
|
18
|
+
203.0.113.0/24 224.0.0.0/4 240.0.0.0/4 ::/128 64:ff9b::/96 100::/64 fc00::/7 fe80::/10 ff00::/8
|
|
19
|
+
].map { |cidr| IPAddr.new(cidr) }.freeze,
|
|
20
|
+
T::Array[IPAddr]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
sig { params(url: String, block_private_ips: T::Boolean).returns(T::Boolean) }
|
|
24
|
+
def self.allowed?(url, block_private_ips:)
|
|
25
|
+
host = web_host(URI.parse(url))
|
|
26
|
+
return false unless host
|
|
27
|
+
return true unless block_private_ips
|
|
28
|
+
|
|
29
|
+
addresses = resolve(host)
|
|
30
|
+
!addresses.empty? && addresses.none? { |address| blocked?(address) }
|
|
31
|
+
rescue URI::Error
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sig { params(uri: URI::Generic).returns(T.nilable(String)) }
|
|
36
|
+
def self.web_host(uri)
|
|
37
|
+
host = uri.hostname
|
|
38
|
+
host if SCHEMES.include?(uri.scheme&.downcase) && host && !host.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { params(host: String).returns(T::Array[IPAddr]) }
|
|
42
|
+
def self.resolve(host)
|
|
43
|
+
literal = parse_ip(host)
|
|
44
|
+
return [literal] if literal
|
|
45
|
+
|
|
46
|
+
Addrinfo.getaddrinfo(host, nil, nil, :STREAM).filter_map { |info| parse_ip(info.ip_address) }
|
|
47
|
+
rescue SocketError
|
|
48
|
+
[]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { params(value: String).returns(T.nilable(IPAddr)) }
|
|
52
|
+
def self.parse_ip(value)
|
|
53
|
+
IPAddr.new(value.sub(/%.*\z/, ''))
|
|
54
|
+
rescue IPAddr::Error
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { params(address: IPAddr).returns(T::Boolean) }
|
|
59
|
+
def self.blocked?(address)
|
|
60
|
+
address = address.native if address.ipv4_mapped?
|
|
61
|
+
address.private? || address.loopback? || address.link_local? ||
|
|
62
|
+
BLOCKED_RANGES.any? { |range| range.family == address.family && range.include?(address) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private_class_method :resolve, :parse_ip, :blocked?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/brand_logo/icon.rb
CHANGED
|
@@ -1,34 +1,55 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
# typed: strict
|
|
3
3
|
|
|
4
|
-
require 'sorbet-runtime'
|
|
5
|
-
|
|
6
4
|
module BrandLogo
|
|
7
|
-
|
|
8
|
-
# Used to store and return brand_logo information across the gem
|
|
9
|
-
class Icon
|
|
5
|
+
Icon = Data.define(:url, :format, :dimensions, :source, :kind, :media) do
|
|
10
6
|
extend T::Sig
|
|
11
7
|
|
|
12
|
-
sig { returns(String) }
|
|
13
|
-
attr_reader :url
|
|
14
|
-
|
|
15
|
-
sig { returns(T::Hash[Symbol, T.nilable(Integer)]) }
|
|
16
|
-
attr_reader :dimensions
|
|
17
|
-
|
|
18
|
-
sig { returns(String) }
|
|
19
|
-
attr_reader :format
|
|
20
|
-
|
|
21
8
|
sig do
|
|
22
9
|
params(
|
|
23
10
|
url: String,
|
|
24
|
-
|
|
25
|
-
format:
|
|
11
|
+
source: Symbol,
|
|
12
|
+
format: T.nilable(Symbol),
|
|
13
|
+
dimensions: Dimensions,
|
|
14
|
+
kind: Symbol,
|
|
15
|
+
media: T.nilable(String)
|
|
26
16
|
).void
|
|
27
17
|
end
|
|
28
|
-
def initialize(url:,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
18
|
+
def initialize(url:, source:, format: nil, dimensions: Dimensions.new, kind: :icon, media: nil)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
sig { params(hash: T::Hash[T.any(String, Symbol), T.untyped]).returns(Icon) }
|
|
23
|
+
def self.from_h(hash)
|
|
24
|
+
data = hash.transform_keys(&:to_sym)
|
|
25
|
+
new(
|
|
26
|
+
url: data.fetch(:url),
|
|
27
|
+
source: data.fetch(:source).to_sym,
|
|
28
|
+
format: data[:format]&.to_sym,
|
|
29
|
+
dimensions: Dimensions.new(width: data[:width], height: data[:height]),
|
|
30
|
+
kind: data.fetch(:kind, :icon).to_sym,
|
|
31
|
+
media: data[:media]
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sig { returns(T.nilable(Integer)) }
|
|
36
|
+
def width
|
|
37
|
+
dimensions.width
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { returns(T.nilable(Integer)) }
|
|
41
|
+
def height
|
|
42
|
+
dimensions.height
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { returns(T::Boolean) }
|
|
46
|
+
def svg?
|
|
47
|
+
format == :svg
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
51
|
+
def to_h
|
|
52
|
+
{ url: url, format: format, width: width, height: height, source: source, kind: kind, media: media }
|
|
32
53
|
end
|
|
33
54
|
end
|
|
34
55
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module BrandLogo
|
|
7
|
+
module ImageFormat
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
MIME_TYPES = T.let({
|
|
11
|
+
'image/x-icon' => :ico,
|
|
12
|
+
'image/vnd.microsoft.icon' => :ico,
|
|
13
|
+
'image/ico' => :ico,
|
|
14
|
+
'image/png' => :png,
|
|
15
|
+
'image/svg+xml' => :svg,
|
|
16
|
+
'image/jpeg' => :jpg,
|
|
17
|
+
'image/jpg' => :jpg,
|
|
18
|
+
'image/webp' => :webp,
|
|
19
|
+
'image/gif' => :gif,
|
|
20
|
+
'image/avif' => :avif,
|
|
21
|
+
'image/bmp' => :bmp
|
|
22
|
+
}.freeze, T::Hash[String, Symbol])
|
|
23
|
+
|
|
24
|
+
EXTENSIONS = T.let({
|
|
25
|
+
'ico' => :ico, 'cur' => :ico, 'png' => :png, 'svg' => :svg, 'jpg' => :jpg, 'jpeg' => :jpg,
|
|
26
|
+
'webp' => :webp, 'gif' => :gif, 'avif' => :avif, 'bmp' => :bmp
|
|
27
|
+
}.freeze, T::Hash[String, Symbol])
|
|
28
|
+
|
|
29
|
+
FASTIMAGE_TYPES = T.let({ jpeg: :jpg, cur: :ico }.freeze, T::Hash[Symbol, Symbol])
|
|
30
|
+
|
|
31
|
+
sig { params(mime: T.untyped).returns(T.nilable(Symbol)) }
|
|
32
|
+
def self.from_mime(mime)
|
|
33
|
+
MIME_TYPES[mime.to_s.split(';').first.to_s.strip.downcase]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { params(url: String).returns(T.nilable(Symbol)) }
|
|
37
|
+
def self.from_url(url)
|
|
38
|
+
return from_mime(url[/\Adata:([^;,]+)/i, 1]) if url.start_with?('data:')
|
|
39
|
+
|
|
40
|
+
path = URI.parse(url).path.to_s
|
|
41
|
+
EXTENSIONS[File.extname(path).delete_prefix('.').downcase]
|
|
42
|
+
rescue URI::Error
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { params(type: T.nilable(Symbol)).returns(T.nilable(Symbol)) }
|
|
47
|
+
def self.from_fastimage(type)
|
|
48
|
+
return nil if type.nil?
|
|
49
|
+
|
|
50
|
+
FASTIMAGE_TYPES.fetch(type, type)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|