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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da46bcee230e23bbe43d0a20b21c13ef2eab0dd2faebcf8abfcf51ccb4d54fe0
4
- data.tar.gz: 7c0888790d4919fa0e8d7cbbbfccdd3f5cbebc457f4206c64f34950f51cf423e
3
+ metadata.gz: 8ca5db19ef68c02172defd8e04ba4a228c5819c32e3f2aa8dd10de412b4889f1
4
+ data.tar.gz: 6a1bf3a2bda344977f005a0c12ae07c884fa24bc19d5bdabc650ccb57f2e9806
5
5
  SHA512:
6
- metadata.gz: e589a8a91d7c41349997f0ed68a4f6f9ea3d6f53f2a7f98b796bf6491b3bc91b7301390db0074e200dcdfec21152bc88230d48605d0ec17e9bf7deb55eab4f68
7
- data.tar.gz: b0b0763a1ec0a9ee78840997b9b759b0ff13fc94729db0426c24ddd3acb79f52d92c5f37c0d878fb047035f21915136da14b78026e4e2535eead61a73f471f73
6
+ metadata.gz: ee0e662864b0ede5a0a78b538109468efc96db273c41bb37ccb20c36f808e3869258e5e29792c8dfcb5a7410fc3c4fe45ffa8e9f6a3453197e421abc80a08173
7
+ data.tar.gz: 74c9c3d4fe4102fdc86bd2070129dc93b9f7b09da79677278c1ea0b3e4bc1cfdc6779195f4b800e0ea9d410c71b0f2d72f89c3ab59354d40b34385bac48557f3
data/CHANGELOG.md CHANGED
@@ -3,6 +3,62 @@
3
3
  All notable changes to this project will be documented here.
4
4
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [2.0.0] - 2026-10-02
7
+
8
+ Rewrite focused on picking the right icon, speed and safety. Breaking changes: see *Upgrading*.
9
+
10
+ ### Added
11
+
12
+ - `JsonLd` strategy: schema.org `Organization` / `Brand` logos (including `@graph` and `ImageObject`).
13
+ - `Browserconfig` strategy (`msapplication-config`) and `msapplication-TileImage` / `og:logo` meta tags.
14
+ - `Google` favicon service fallback, before DuckDuckGo; both can be disabled with `external_fallbacks: []`.
15
+ - `Fetcher#fetch!`, `Fetcher#fetch_many` (concurrent), URL input (`fetch('https://github.com/about')`).
16
+ - Caching: in-memory by default, or any `read`/`write(expires_in:)` store such as `Rails.cache`; misses use `negative_cache_ttl`.
17
+ - Ranking options: `prefer` (`:square`, `:largest`, `:svg`), `target_size`, `color_scheme` (`media` attribute).
18
+ - `deadline` (total time per lookup), `user_agent`, `max_body_bytes`, `concurrency`, `block_private_ips`.
19
+ - `Icon#source`, `#kind`, `#media`, `#width`, `#height`, `#to_h`, `Icon.from_h`.
20
+
21
+ ### Changed
22
+
23
+ - `fetch` returns the best icon across strategies (stopping early once one is good enough) instead of the first strategy's result.
24
+ - Every candidate is probed: dead links, HTML soft-404s and non-images are dropped; the format comes from the image bytes.
25
+ - The homepage is fetched once per lookup (was up to 7 times); probes are deduplicated, run in parallel and read 64 KB at most.
26
+ - Safari mask icons, monochrome icons and social banners rank last; PWA manifest icons beat `og:image`.
27
+ - Redirects are followed for every request and the final URL (or `<base href>`) is used to resolve relative links.
28
+ - Requests send a browser-like `User-Agent`; `timeout` is now a total per request (default 5 s).
29
+ - `sizes="16x16 32x32"`, uppercase `X` and query strings (`/icon.png?v=2`) are understood.
30
+ - Domain validation accepts URLs and mixed case, rejects malformed labels (`-a.com`, `..com`).
31
+ - `sorbet-runtime` is no longer a runtime dependency; `http` and `nokogiri` versions are bounded.
32
+
33
+ ### Security
34
+
35
+ - SSRF protection (`block_private_ips`, on by default): private, loopback and link-local addresses are refused at every redirect; only `http`/`https` URLs are fetched.
36
+
37
+ ### Removed
38
+
39
+ - `ScrapingStrategy` (now `LinkTag`), `MetaTagStrategy`, `ManifestStrategy`, `DuckduckgoStrategy` (now `Strategies::MetaTag`, `Manifest`, `Duckduckgo`).
40
+ - `HttpClient`, `HtmlParser`, `ImageAnalyzer` interfaces: inject a `BrandLogo::Http::Client` instead.
41
+ - `FetchError`, `ParseError`.
42
+
43
+ ### Upgrading from 1.x
44
+
45
+ | 1.x | 2.0 |
46
+ |---|---|
47
+ | `fetch(domain)` raises `NoIconFoundError` | `fetch` returns `nil`; use `fetch!` to raise |
48
+ | `icon.dimensions[:width]` | `icon.width` (or `icon.dimensions.width`) |
49
+ | `icon.format == 'svg'` | `icon.format == :svg` |
50
+ | `Config.new(min_dimensions: { width: 32, height: 32 })` | `Config.new(min_size: 32)` |
51
+ | `max_dimensions:` | `max_size:` |
52
+ | `Fetcher.new(strategies: [ScrapingStrategy.new(config:, http_client:, …)])` | `Fetcher.new(strategies: [Strategies::LinkTag.new])` |
53
+ | custom strategy: subclass `BaseStrategy`, implement `fetch_all(domain)` | `include Strategies::Base`, implement `stage` and `call(context)` |
54
+
55
+ ## [1.0.1] - 2026-07-23
56
+
57
+ ### Changed
58
+
59
+ - Publish to RubyGems via trusted publishing (OIDC) instead of a long-lived API key
60
+ - Bump simplecov to 1.0.0, migrate `add_filter` to `skip`
61
+
6
62
  ## [1.0.0] - 2026-04-15
7
63
 
8
64
  First public release under the name `brand_logo`.
data/README.md CHANGED
@@ -3,11 +3,24 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/brand_logo.svg)](https://badge.fury.io/rb/brand_logo)
4
4
  [![Test](https://github.com/AdVitam/brand_logo/actions/workflows/main.yml/badge.svg)](https://github.com/AdVitam/brand_logo/actions/workflows/main.yml)
5
5
 
6
- Fetch the best logo or icon for any website from its domain.
6
+ **Give it a domain, get back the best logo that site has to offer.**
7
7
 
8
- `brand_logo` chains several strategies (favicon tags, Open Graph / Twitter meta
9
- images, PWA web app manifests, DuckDuckGo fallback) and returns the best icon
10
- based on format and dimensions.
8
+ ```ruby
9
+ BrandLogo::Fetcher.new.fetch('github.com').url # => "https://github.com/fluidicon.png"
10
+ ```
11
+
12
+ Perfect for company directories, CRM enrichment, link previews or any list of brands that
13
+ deserves real logos instead of blurry 16 px favicons.
14
+
15
+ - **The right icon, not the first one** — favicon links, `apple-touch-icon`, schema.org JSON-LD
16
+ logos, Open Graph tags, PWA manifest and `browserconfig.xml` are compared by their *real*
17
+ size and format. Square, crisp and SVG wins; Safari mask icons and social banners lose.
18
+ - **Fast** — one homepage request, image probes run in parallel and read 64 KB at most,
19
+ the lookup stops as soon as a good icon is found, and results are cached (`Rails.cache` ready).
20
+ - **Never empty-handed** — Google and DuckDuckGo favicon services as a last resort.
21
+ - **Safe on untrusted input** — SSRF protection on every redirect, bounded timeouts and body sizes.
22
+ - **Tunable** — prefer square, largest or SVG, target a size, pick dark-mode icons.
23
+ - **Light** — no `sorbet-runtime` dependency, thread-safe, batch lookups with `fetch_many`.
11
24
 
12
25
  ## Installation
13
26
 
@@ -21,78 +34,95 @@ gem 'brand_logo'
21
34
  require 'brand_logo'
22
35
 
23
36
  fetcher = BrandLogo::Fetcher.new
24
- icon = fetcher.fetch('github.com')
25
37
 
26
- icon.url # => "https://github.com/favicon.svg"
27
- icon.format # => "svg"
28
- icon.dimensions # => { width: nil, height: nil }
38
+ icon = fetcher.fetch('github.com') # => BrandLogo::Icon or nil
39
+ icon.url # => "https://github.com/fluidicon.png"
40
+ icon.format # => :png
41
+ icon.width # => 512
42
+ icon.source # => :link_tag
43
+ icon.kind # => :icon
44
+ icon.to_h # => { url:, format:, width:, height:, source:, kind:, media: }
45
+
46
+ fetcher.fetch!('https://github.com/about') # URLs are accepted; raises NoIconFoundError when nothing is found
47
+ fetcher.fetch_all('github.com') # every valid icon, best first
48
+ fetcher.fetch_many(%w[github.com ruby-lang.org]) # => { 'github.com' => Icon, 'ruby-lang.org' => Icon }
29
49
  ```
30
50
 
31
- ### All icons
51
+ A `Fetcher` is thread-safe and meant to be reused (it holds the cache).
32
52
 
33
- ```ruby
34
- icons = fetcher.fetch_all('github.com')
35
- # Every icon found across all strategies, deduplicated by URL
36
- ```
37
-
38
- ### Configuration
53
+ ## Configuration
39
54
 
40
55
  ```ruby
41
56
  config = BrandLogo::Config.new(
42
- min_dimensions: { width: 32, height: 32 }, # ignore tiny icons
43
- max_dimensions: { width: 512, height: 512 }, # ignore oversized images
44
- allow_svg: true, # prefer SVG when available
45
- timeout: 10, # HTTP timeout in seconds
46
- max_hops: 5 # max redirects to follow
57
+ min_size: 32, # Integer, { width:, height: } or BrandLogo::Dimensions
58
+ max_size: { width: 1024, height: 1024 },
59
+ allow_svg: true,
60
+ prefer: :square, # :square, :largest or :svg
61
+ target_size: 192, # pick the icon closest to (and preferably above) this size
62
+ color_scheme: :dark, # favour <link media="(prefers-color-scheme: dark)"> icons
63
+ timeout: 5, # seconds per request
64
+ deadline: 15, # seconds per lookup; the best icon found so far is returned
65
+ max_hops: 5, # redirects per request
66
+ max_body_bytes: 2_000_000,
67
+ user_agent: 'MyApp/1.0',
68
+ concurrency: 4, # parallel probes and fetch_many lookups
69
+ block_private_ips: true, # SSRF protection, see below
70
+ external_fallbacks: %i[google duckduckgo], # [] to never contact third parties
71
+ cache: Rails.cache, # anything with read(key) / write(key, value, expires_in:); nil disables
72
+ cache_ttl: 86_400,
73
+ negative_cache_ttl: 3_600
47
74
  )
48
75
 
49
76
  fetcher = BrandLogo::Fetcher.new(config: config)
77
+ other = BrandLogo::Fetcher.new(config: config.with(prefer: :largest))
50
78
  ```
51
79
 
52
- ### Custom strategy chain
80
+ Invalid settings raise `BrandLogo::ValidationError`. The default cache is an in-memory store
81
+ per `Fetcher`. Lookups that hit the deadline without a result are not cached.
53
82
 
54
- Strategies are tried in order until one succeeds:
83
+ ### Custom strategies
55
84
 
56
85
  ```ruby
57
- fetcher = BrandLogo::Fetcher.new(
58
- strategies: [
59
- BrandLogo::Strategies::ScrapingStrategy.new(config: config, ...),
60
- BrandLogo::Strategies::DuckduckgoStrategy.new(config: config, ...)
61
- ]
62
- )
63
- ```
86
+ class MyStrategy
87
+ include BrandLogo::Strategies::Base
64
88
 
65
- Default chain: `ScrapingStrategy → MetaTagStrategy → ManifestStrategy → DuckduckgoStrategy`
89
+ def stage = :document
66
90
 
67
- ### Logging
91
+ def call(context)
92
+ page = context.page or return []
93
+ url = page.resolve(page.document.at('img.logo')&.[]('src')) or return []
94
+ [BrandLogo::Icon.new(url: url, source: :my_strategy, kind: :logo)]
95
+ end
96
+ end
68
97
 
69
- ```ruby
70
- require 'logger'
71
- BrandLogo::Logging.logger.level = Logger::DEBUG # verbose output
72
- BrandLogo::Logging.logger = MyCustomLogger.new # inject your own logger
98
+ BrandLogo::Fetcher.new(strategies: [MyStrategy.new, BrandLogo::Strategies::LinkTag.new])
73
99
  ```
74
100
 
75
- ## Strategies
101
+ ## Input
102
+
103
+ `fetch` accepts a hostname or a URL (`"GitHub.com "`, `"https://github.com/about"`).
104
+ Internationalized domains must be given in punycode (`xn--…`); IP addresses are rejected.
76
105
 
77
- | Strategy | Source | Notes |
78
- |---|---|---|
79
- | `ScrapingStrategy` | HTML `<link rel="icon">` tags | Primary — tries `https://`, `https://www.`, `http://` |
80
- | `MetaTagStrategy` | `og:image`, `twitter:image` | High-res images, filter via `max_dimensions` |
81
- | `ManifestStrategy` | PWA `manifest.json` `icons[]` | Best for progressive web apps |
82
- | `DuckduckgoStrategy` | DuckDuckGo icon cache | Last-resort fallback |
106
+ ## Security
83
107
 
84
- ## Error handling
108
+ The gem fetches URLs chosen by third-party pages. With `block_private_ips: true` (default),
109
+ every request — including each redirect — is refused unless its host resolves only to public
110
+ addresses, and only `http`/`https` URLs are fetched. A DNS answer that changes between the check
111
+ and the connection (DNS rebinding) is not covered; run lookups from a network without access to
112
+ sensitive internal services if inputs are untrusted.
113
+
114
+ ## Logging
85
115
 
86
116
  ```ruby
87
- begin
88
- icon = fetcher.fetch('example.com')
89
- rescue BrandLogo::NoIconFoundError
90
- # no icon found by any strategy
91
- rescue BrandLogo::ValidationError => e
92
- # invalid domain format: e.message
93
- end
117
+ BrandLogo::Logging.logger.level = Logger::DEBUG
118
+ BrandLogo::Logging.logger = Rails.logger
94
119
  ```
95
120
 
121
+ ## Sorbet
122
+
123
+ The gem is typed with Sorbet but does not depend on `sorbet-runtime`: signatures are checked
124
+ statically in CI, and at runtime the gem uses `sorbet-runtime` only if your bundle already has it.
125
+
96
126
  ## Requirements
97
127
 
98
128
  - Ruby >= 3.2
@@ -102,6 +132,13 @@ end
102
132
  Bug reports and pull requests are welcome on GitHub at
103
133
  <https://github.com/AdVitam/brand_logo>.
104
134
 
135
+ ```bash
136
+ bundle exec rspec
137
+ bundle exec rubocop
138
+ bundle exec srb tc
139
+ E2E=1 bundle exec rspec --tag e2e # hits real websites
140
+ ```
141
+
105
142
  ## License
106
143
 
107
144
  Released under the MIT License. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module BrandLogo
5
+ # Duck-typed on read / write(expires_in:) so Rails.cache can be passed as is.
6
+ module Cache
7
+ class Memory
8
+ extend T::Sig
9
+
10
+ sig { params(max_entries: Integer).void }
11
+ def initialize(max_entries: 1_000)
12
+ @max_entries = max_entries
13
+ @entries = T.let({}, T::Hash[String, [T.untyped, Float]])
14
+ @mutex = T.let(Mutex.new, Mutex)
15
+ end
16
+
17
+ sig { params(key: String).returns(T.untyped) }
18
+ def read(key)
19
+ @mutex.synchronize do
20
+ entry = @entries[key]
21
+ return nil unless entry
22
+
23
+ value, expires_at = entry
24
+ return value if now < expires_at
25
+
26
+ @entries.delete(key)
27
+ nil
28
+ end
29
+ end
30
+
31
+ sig { params(key: String, value: T.untyped, expires_in: T.any(Integer, Float)).void }
32
+ def write(key, value, expires_in:)
33
+ @mutex.synchronize do
34
+ @entries.delete(key)
35
+ @entries[key] = [value, now + expires_in]
36
+ @entries.shift while @entries.size > @max_entries
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ sig { returns(Float) }
43
+ def now
44
+ Process.clock_gettime(Process::CLOCK_MONOTONIC).to_f
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module BrandLogo
5
+ module Concurrency
6
+ extend T::Sig
7
+
8
+ sig do
9
+ type_parameters(:I, :O)
10
+ .params(
11
+ items: T::Array[T.type_parameter(:I)],
12
+ size: Integer,
13
+ block: T.proc.params(item: T.type_parameter(:I)).returns(T.type_parameter(:O))
14
+ )
15
+ .returns(T::Array[T.type_parameter(:O)])
16
+ end
17
+ def self.map(items, size:, &block)
18
+ return items.map(&block) if items.size <= 1 || size <= 1
19
+
20
+ results = T.let(Array.new(items.size), T::Array[T.untyped])
21
+ failures = Queue.new
22
+ queue = Queue.new(items.each_index.to_a).close
23
+
24
+ spawn([size, items.size].min) { work(queue, failures) { |index| results[index] = yield(items.fetch(index)) } }
25
+ raise failures.pop unless failures.empty?
26
+
27
+ results
28
+ end
29
+
30
+ sig { params(count: Integer, block: T.proc.void).void }
31
+ def self.spawn(count, &block)
32
+ Array.new(count) do
33
+ Thread.new do
34
+ Thread.current.report_on_exception = false
35
+ yield
36
+ end
37
+ end.each(&:join)
38
+ end
39
+
40
+ sig { params(queue: Thread::Queue, failures: Thread::Queue, block: T.proc.params(index: Integer).void).void }
41
+ def self.work(queue, failures, &block)
42
+ while (index = queue.pop)
43
+ begin
44
+ yield(index)
45
+ rescue StandardError => e
46
+ failures << e
47
+ queue.clear
48
+ end
49
+ end
50
+ end
51
+ private_class_method :spawn, :work
52
+ end
53
+ end
@@ -1,60 +1,112 @@
1
1
  # frozen_string_literal: true
2
2
  # typed: strict
3
3
 
4
- require 'sorbet-runtime'
4
+ require 'digest'
5
5
 
6
6
  module BrandLogo
7
- # Immutable configuration object for the gem.
8
- # Centralizes all runtime parameters, replacing the 4 primitive kwargs
9
- # previously duplicated across every strategy constructor.
10
- #
11
- # Usage:
12
- # config = BrandLogo::Config.new(min_dimensions: { width: 32, height: 32 }, timeout: 5)
13
- class Config
7
+ Config = Data.define(
8
+ :min_size, :max_size, :allow_svg, :prefer, :target_size, :color_scheme,
9
+ :timeout, :deadline, :max_hops, :max_body_bytes, :user_agent, :concurrency,
10
+ :block_private_ips, :external_fallbacks, :cache, :cache_ttl, :negative_cache_ttl
11
+ ) do
14
12
  extend T::Sig
15
13
 
16
- DEFAULT_TIMEOUT = T.let(10, Integer)
17
- DEFAULT_MAX_HOPS = T.let(5, Integer)
18
- DEFAULT_MIN_DIMENSIONS = T.let({ width: 0, height: 0 }.freeze, T::Hash[Symbol, Integer])
19
- DEFAULT_DIMENSIONS = T.let({ width: 16, height: 16 }.freeze, T::Hash[Symbol, Integer])
20
- DEFAULT_FAVICON_PATH = T.let('/favicon.ico', String)
21
-
22
- sig { returns(T::Hash[Symbol, Integer]) }
23
- attr_reader :min_dimensions
24
-
25
- sig { returns(T.nilable(T::Hash[Symbol, Integer])) }
26
- attr_reader :max_dimensions
27
-
28
- sig { returns(T::Boolean) }
29
- attr_reader :allow_svg
30
-
31
- sig { returns(Integer) }
32
- attr_reader :timeout
33
-
34
- sig { returns(Integer) }
35
- attr_reader :max_hops
36
-
37
14
  sig do
38
15
  params(
39
- min_dimensions: T::Hash[Symbol, Integer],
40
- max_dimensions: T.nilable(T::Hash[Symbol, Integer]),
16
+ min_size: T.untyped,
17
+ max_size: T.untyped,
41
18
  allow_svg: T::Boolean,
42
- timeout: Integer,
43
- max_hops: Integer
19
+ prefer: Symbol,
20
+ target_size: T.nilable(Integer),
21
+ color_scheme: T.nilable(Symbol),
22
+ timeout: Numeric,
23
+ deadline: Numeric,
24
+ max_hops: Integer,
25
+ max_body_bytes: Integer,
26
+ user_agent: String,
27
+ concurrency: Integer,
28
+ block_private_ips: T::Boolean,
29
+ external_fallbacks: T::Array[Symbol],
30
+ cache: T.untyped,
31
+ cache_ttl: Integer,
32
+ negative_cache_ttl: Integer
44
33
  ).void
45
34
  end
46
35
  def initialize(
47
- min_dimensions: DEFAULT_MIN_DIMENSIONS,
48
- max_dimensions: nil,
49
- allow_svg: true,
50
- timeout: DEFAULT_TIMEOUT,
51
- max_hops: DEFAULT_MAX_HOPS
36
+ min_size: nil, max_size: nil, allow_svg: true, prefer: :square, target_size: nil, color_scheme: nil,
37
+ timeout: 5, deadline: 15, max_hops: 5, max_body_bytes: 2_000_000, user_agent: Config::USER_AGENT,
38
+ concurrency: 4, block_private_ips: true, external_fallbacks: Config::EXTERNAL_FALLBACKS, cache: Cache::Memory.new,
39
+ cache_ttl: 86_400, negative_cache_ttl: 3_600
52
40
  )
53
- @min_dimensions = T.let(min_dimensions, T::Hash[Symbol, Integer])
54
- @max_dimensions = T.let(max_dimensions, T.nilable(T::Hash[Symbol, Integer]))
55
- @allow_svg = T.let(allow_svg, T::Boolean)
56
- @timeout = T.let(timeout, Integer)
57
- @max_hops = T.let(max_hops, Integer)
41
+ super(
42
+ min_size: Dimensions.coerce(min_size), max_size: Dimensions.coerce(max_size), allow_svg: allow_svg,
43
+ prefer: prefer, target_size: target_size, color_scheme: color_scheme, timeout: timeout,
44
+ deadline: deadline, max_hops: max_hops, max_body_bytes: max_body_bytes, user_agent: user_agent,
45
+ concurrency: concurrency, block_private_ips: block_private_ips,
46
+ external_fallbacks: external_fallbacks.dup.freeze, cache: cache, cache_ttl: cache_ttl,
47
+ negative_cache_ttl: negative_cache_ttl
48
+ )
49
+ validate!
50
+ end
51
+
52
+ # Data#with skips initialize before Ruby 3.3, which would bypass coercion and validation.
53
+ sig { params(changes: T.untyped).returns(Config) }
54
+ def with(**changes)
55
+ self.class.new(**to_h, **changes)
56
+ end
57
+
58
+ # Identifies the settings that change which icon wins, so cached results never leak across them.
59
+ sig { returns(String) }
60
+ def cache_digest
61
+ Digest::SHA256.hexdigest(
62
+ [min_size, max_size, allow_svg, prefer, target_size, color_scheme, external_fallbacks,
63
+ block_private_ips].inspect
64
+ )[0, 12].to_s
65
+ end
66
+
67
+ private
68
+
69
+ sig { void }
70
+ def validate!
71
+ validate_choices!
72
+ validate_numbers!
73
+ check(cache.nil? || (cache.respond_to?(:read) && cache.respond_to?(:write)), 'cache must respond to read/write')
74
+ check(!(min_size && max_size) || T.must(max_size).at_least?(T.must(min_size)), 'max_size must be >= min_size')
75
+ end
76
+
77
+ sig { void }
78
+ def validate_choices!
79
+ check(Config::PREFERENCES.include?(prefer), "prefer must be one of #{Config::PREFERENCES}")
80
+ check(color_scheme.nil? || Config::COLOR_SCHEMES.include?(color_scheme),
81
+ "color_scheme must be nil or one of #{Config::COLOR_SCHEMES}")
82
+ check((external_fallbacks - Config::EXTERNAL_FALLBACKS).empty?,
83
+ "external_fallbacks must be within #{Config::EXTERNAL_FALLBACKS}")
84
+ end
85
+
86
+ sig { void }
87
+ def validate_numbers!
88
+ size = target_size
89
+ check(size.nil? || size.positive?, 'target_size must be positive')
90
+ %i[timeout deadline max_body_bytes concurrency].each do |key|
91
+ check(public_send(key).positive?, "#{key} must be positive")
92
+ end
93
+ %i[max_hops cache_ttl negative_cache_ttl].each { |key| check(!public_send(key).negative?, "#{key} must be >= 0") }
58
94
  end
95
+
96
+ sig { params(condition: T::Boolean, message: String).void }
97
+ def check(condition, message)
98
+ raise ValidationError, message unless condition
99
+ end
100
+ end
101
+
102
+ # Reopened because constants defined inside a Data.define block would land on BrandLogo.
103
+ class Config
104
+ PREFERENCES = T.let(%i[square largest svg].freeze, T::Array[Symbol])
105
+ COLOR_SCHEMES = T.let(%i[light dark].freeze, T::Array[Symbol])
106
+ EXTERNAL_FALLBACKS = T.let(%i[google duckduckgo].freeze, T::Array[Symbol])
107
+ USER_AGENT = T.let(
108
+ 'Mozilla/5.0 (compatible; BrandLogo/2; +https://github.com/AdVitam/brand_logo)',
109
+ String
110
+ )
59
111
  end
60
112
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ require 'uri'
5
+
6
+ module BrandLogo
7
+ class Context
8
+ extend T::Sig
9
+
10
+ sig { returns(String) }
11
+ attr_reader :domain
12
+
13
+ sig { returns(Config) }
14
+ attr_reader :config
15
+
16
+ sig { returns(Deadline) }
17
+ attr_reader :deadline
18
+
19
+ sig { params(domain: String, config: Config, http: Http::Client, deadline: T.nilable(Deadline)).void }
20
+ def initialize(domain:, config:, http:, deadline: nil)
21
+ @domain = domain
22
+ @config = config
23
+ @http = http
24
+ @deadline = T.let(deadline || Deadline.new(config.deadline), Deadline)
25
+ @lock = T.let(Mutex.new, Mutex)
26
+ @page = T.let(nil, T.nilable(Page))
27
+ @page_loaded = T.let(false, T::Boolean)
28
+ @probes = T.let({}, T::Hash[String, T.nilable(ImageProbe::Result)])
29
+ end
30
+
31
+ sig { params(url: String, max_bytes: T.nilable(Integer), range: T.nilable(T::Range[Integer])).returns(T.nilable(Http::Response)) }
32
+ def get(url, max_bytes: nil, range: nil)
33
+ @http.get(url, deadline: @deadline, max_bytes: max_bytes || @config.max_body_bytes, range: range)
34
+ end
35
+
36
+ sig { returns(T.nilable(Page)) }
37
+ def page
38
+ @lock.synchronize do
39
+ unless @page_loaded
40
+ @page = Page.fetch(self)
41
+ @page_loaded = true
42
+ end
43
+ @page
44
+ end
45
+ end
46
+
47
+ sig { params(url: String).returns(T.nilable(ImageProbe::Result)) }
48
+ def probe(url)
49
+ return @probes[url] if @lock.synchronize { @probes.key?(url) }
50
+
51
+ body = url.start_with?('data:') ? decode_data_uri(url) : get(url, max_bytes: ImageProbe::BYTES, range: 0...ImageProbe::BYTES)&.body
52
+ result = body && ImageProbe.analyze(body)
53
+ @lock.synchronize { @probes[url] = result }
54
+ end
55
+
56
+ private
57
+
58
+ sig { params(url: String).returns(T.nilable(String)) }
59
+ def decode_data_uri(url)
60
+ header, payload = url.split(',', 2)
61
+ return nil unless header && payload
62
+
63
+ header.end_with?(';base64') ? payload.unpack1('m') : URI.decode_www_form_component(payload)
64
+ rescue ArgumentError
65
+ nil
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module BrandLogo
5
+ class Deadline
6
+ extend T::Sig
7
+
8
+ sig { params(seconds: Numeric).void }
9
+ def initialize(seconds)
10
+ @expires_at = T.let(now + seconds.to_f, Float)
11
+ end
12
+
13
+ sig { returns(Float) }
14
+ def remaining
15
+ [@expires_at - now, 0.0].max
16
+ end
17
+
18
+ sig { returns(T::Boolean) }
19
+ def expired?
20
+ remaining.zero?
21
+ end
22
+
23
+ private
24
+
25
+ sig { returns(Float) }
26
+ def now
27
+ Process.clock_gettime(Process::CLOCK_MONOTONIC).to_f
28
+ end
29
+ end
30
+ end