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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/README.md +87 -50
  4. data/lib/brand_logo/cache.rb +48 -0
  5. data/lib/brand_logo/concurrency.rb +53 -0
  6. data/lib/brand_logo/config.rb +95 -43
  7. data/lib/brand_logo/context.rb +68 -0
  8. data/lib/brand_logo/deadline.rb +30 -0
  9. data/lib/brand_logo/dimensions.rb +79 -0
  10. data/lib/brand_logo/domain.rb +64 -0
  11. data/lib/brand_logo/errors.rb +1 -3
  12. data/lib/brand_logo/fetcher.rb +78 -81
  13. data/lib/brand_logo/http/client.rb +25 -0
  14. data/lib/brand_logo/http/real_client.rb +110 -0
  15. data/lib/brand_logo/http/response.rb +8 -0
  16. data/lib/brand_logo/http/url_guard.rb +68 -0
  17. data/lib/brand_logo/icon.rb +41 -20
  18. data/lib/brand_logo/image_format.rb +53 -0
  19. data/lib/brand_logo/image_probe.rb +29 -0
  20. data/lib/brand_logo/logging.rb +1 -7
  21. data/lib/brand_logo/lookup.rb +94 -0
  22. data/lib/brand_logo/page.rb +37 -0
  23. data/lib/brand_logo/ranker.rb +115 -0
  24. data/lib/brand_logo/sorbet.rb +8 -0
  25. data/lib/brand_logo/sorbet_shim.rb +44 -0
  26. data/lib/brand_logo/strategies/base.rb +19 -0
  27. data/lib/brand_logo/strategies/browserconfig.rb +70 -0
  28. data/lib/brand_logo/strategies/duckduckgo.rb +21 -0
  29. data/lib/brand_logo/strategies/google.rb +29 -0
  30. data/lib/brand_logo/strategies/json_ld.rb +91 -0
  31. data/lib/brand_logo/strategies/link_tag.rb +58 -0
  32. data/lib/brand_logo/strategies/manifest.rb +68 -0
  33. data/lib/brand_logo/strategies/meta_tag.rb +80 -0
  34. data/lib/brand_logo/url_resolver.rb +30 -0
  35. data/lib/brand_logo/version.rb +1 -1
  36. data/lib/brand_logo.rb +30 -28
  37. metadata +42 -30
  38. data/lib/brand_logo/html_parser.rb +0 -63
  39. data/lib/brand_logo/http_client.rb +0 -59
  40. data/lib/brand_logo/image_analyzer.rb +0 -36
  41. data/lib/brand_logo/strategies/base_strategy.rb +0 -117
  42. data/lib/brand_logo/strategies/duckduckgo_strategy.rb +0 -45
  43. data/lib/brand_logo/strategies/manifest_strategy.rb +0 -137
  44. data/lib/brand_logo/strategies/meta_tag_strategy.rb +0 -95
  45. data/lib/brand_logo/strategies/scraping/default_favicon_checker.rb +0 -33
  46. data/lib/brand_logo/strategies/scraping/dimensions_extractor.rb +0 -42
  47. data/lib/brand_logo/strategies/scraping/format_extractor.rb +0 -56
  48. data/lib/brand_logo/strategies/scraping/icon_finder.rb +0 -79
  49. data/lib/brand_logo/strategies/scraping/url_normalizer.rb +0 -50
  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
@@ -3,8 +3,6 @@
3
3
 
4
4
  module BrandLogo
5
5
  class Error < StandardError; end
6
- class FetchError < Error; end
7
- class ParseError < Error; end
8
6
  class ValidationError < Error; end
9
- class NoIconFoundError < FetchError; end
7
+ class NoIconFoundError < Error; end
10
8
  end
@@ -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
- DOMAIN_PATTERN = T.let(/\A[a-z0-9\-.]+\.[a-z]{2,}\z/i, Regexp)
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: T.nilable(Config),
34
- strategies: T.nilable(T::Array[Strategies::BaseStrategy])
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 = T.let(config || Config.new, Config)
39
- @http_client = T.let(RealHttpClient.new(@config), HttpClient)
40
- @image_analyzer = T.let(FastimageAnalyzer.new, ImageAnalyzer)
41
- @html_parser = T.let(NokogiriParser.new, HtmlParser)
42
- @strategies = T.let(strategies || build_default_strategies, T::Array[Strategies::BaseStrategy])
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
- # Returns the best icon found for the domain across all strategies.
46
- # Raises NoIconFoundError if no strategy finds a valid icon.
47
- sig { params(domain: String).returns(Icon) }
48
- def fetch(domain)
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
- raise NoIconFoundError, "No brand_logo found for #{domain}"
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
- # Returns all icons found across every strategy, deduplicated by URL.
62
- sig { params(domain: String).returns(T::Array[Icon]) }
63
- def fetch_all(domain)
64
- validate_domain!(domain)
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
- @strategies
67
- .flat_map { |strategy| strategy.fetch_all(domain) }
68
- .uniq(&:url)
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).void }
74
- def validate_domain!(domain)
75
- return if domain.match?(DOMAIN_PATTERN)
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
- raise ValidationError, "Invalid domain: #{domain.inspect}"
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::BaseStrategy]) }
81
- def build_default_strategies
82
- [
83
- Strategies::ScrapingStrategy.new(
84
- config: @config,
85
- http_client: @http_client,
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,8 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module BrandLogo
5
+ module Http
6
+ Response = Data.define(:url, :body)
7
+ end
8
+ 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
@@ -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
- # Represents a brand_logo icon with its URL, dimensions and format
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
- dimensions: T::Hash[Symbol, T.nilable(Integer)],
25
- format: String
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:, dimensions:, format:)
29
- @url = url
30
- @dimensions = dimensions
31
- @format = format
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