nondisposable 0.3.0 → 0.6.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 +73 -0
- data/README.md +305 -3
- data/Rakefile +5 -0
- data/app/models/nondisposable/disposable_domain.rb +53 -34
- data/app/models/nondisposable/source_snapshot.rb +66 -0
- data/data/email_providers.txt +276 -0
- data/data/iana_tlds.txt +1439 -0
- data/data/known_typo_domains.txt +99 -0
- data/data/never_disposable.txt +480 -0
- data/data/privacy_relays.txt +42 -0
- data/data/public_suffix_list.dat +16571 -0
- data/gemfiles/rails_7.2.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/lib/generators/nondisposable/install_generator.rb +1 -0
- data/lib/generators/nondisposable/templates/create_nondisposable_source_snapshots.rb.erb +42 -0
- data/lib/generators/nondisposable/templates/nondisposable.rb +67 -1
- data/lib/generators/nondisposable/upgrade_generator.rb +43 -0
- data/lib/nondisposable/confirmable_email.rb +75 -0
- data/lib/nondisposable/domain.rb +238 -0
- data/lib/nondisposable/domain_list_updater.rb +510 -88
- data/lib/nondisposable/email_validator.rb +101 -3
- data/lib/nondisposable/known_typos.rb +64 -0
- data/lib/nondisposable/precedence.rb +209 -0
- data/lib/nondisposable/public_suffix.rb +147 -0
- data/lib/nondisposable/public_suffix_list_updater.rb +88 -0
- data/lib/nondisposable/source.rb +233 -0
- data/lib/nondisposable/source_fetcher.rb +69 -0
- data/lib/nondisposable/source_merge.rb +127 -0
- data/lib/nondisposable/suggestion.rb +336 -0
- data/lib/nondisposable/tld.rb +190 -0
- data/lib/nondisposable/tld_list_updater.rb +101 -0
- data/lib/nondisposable/version.rb +1 -1
- data/lib/nondisposable.rb +153 -6
- data/lib/tasks/nondisposable.rake +66 -0
- metadata +24 -2
|
@@ -1,126 +1,548 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require '
|
|
4
|
-
require '
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'monitor'
|
|
5
|
+
require 'set'
|
|
5
6
|
|
|
6
7
|
module Nondisposable
|
|
8
|
+
# Keeps nondisposable_disposable_domains current.
|
|
9
|
+
#
|
|
10
|
+
# TWO PATHS, CHOSEN BY THE SCHEMA
|
|
11
|
+
#
|
|
12
|
+
# - With the nondisposable_source_snapshots table (fresh installs from 0.6.0,
|
|
13
|
+
# or `rails g nondisposable:upgrade`): every source in config.sources is
|
|
14
|
+
# fetched into its own snapshot, then the stored list is REBUILT from each
|
|
15
|
+
# source's last good snapshot (#publish). One bad source costs its own
|
|
16
|
+
# update, never the list.
|
|
17
|
+
# - Without it: the single-source behaviour of 0.5 and earlier — fetch the
|
|
18
|
+
# default list and replace the table. Rolling deploys are safe in both
|
|
19
|
+
# directions: new code before the migration takes this path, and old code
|
|
20
|
+
# after the migration simply never looks at the new table.
|
|
21
|
+
#
|
|
22
|
+
# Both paths go through the same precedence table (Nondisposable::Precedence)
|
|
23
|
+
# and the same writer, under the same lock.
|
|
24
|
+
#
|
|
25
|
+
# WHAT IS NEVER PUBLISHED
|
|
26
|
+
#
|
|
27
|
+
# An empty list; a list built while any PRIMARY source has never been fetched
|
|
28
|
+
# successfully (on a first run that would replace the bundled seed with less
|
|
29
|
+
# than it); public suffixes; never-disposable domains; anything that is not a
|
|
30
|
+
# possible domain name.
|
|
7
31
|
class DomainListUpdater
|
|
8
|
-
|
|
9
|
-
LIST_URL = 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf'
|
|
32
|
+
LIST_URL = Sources::DISPOSABLE_EMAIL_DOMAINS.url
|
|
10
33
|
|
|
11
34
|
# Snapshot of the upstream blocklist vendored into the gem, used to seed
|
|
12
35
|
# fresh installs so they are protected before the first remote update.
|
|
13
|
-
BUNDLED_LIST_PATH =
|
|
36
|
+
BUNDLED_LIST_PATH = Sources::DISPOSABLE_EMAIL_DOMAINS.bundled_path
|
|
14
37
|
|
|
15
38
|
# Network timeouts (seconds). Without these, a hung connection would block
|
|
16
39
|
# the calling job/thread indefinitely (Net::HTTP defaults to 60s open / 60s read).
|
|
17
|
-
OPEN_TIMEOUT =
|
|
18
|
-
READ_TIMEOUT =
|
|
40
|
+
OPEN_TIMEOUT = SourceFetcher::OPEN_TIMEOUT
|
|
41
|
+
READ_TIMEOUT = SourceFetcher::READ_TIMEOUT
|
|
19
42
|
|
|
20
|
-
|
|
21
|
-
|
|
43
|
+
# A fresh snapshot is refused when it holds fewer than half the domains of
|
|
44
|
+
# the largest snapshot we still retain for that source, or more than three
|
|
45
|
+
# times the last good one. Upstream lists change by a few percent a week;
|
|
46
|
+
# anything bigger is an outage, a truncation or a format change until a
|
|
47
|
+
# human says otherwise. Comparing against the retained MAXIMUM (not just
|
|
48
|
+
# the previous run) is what stops a series of 49% cuts from ratcheting a
|
|
49
|
+
# list down to nothing.
|
|
50
|
+
MAX_SHRINK_RATIO = 0.5
|
|
51
|
+
MAX_GROWTH_FACTOR = 3
|
|
22
52
|
|
|
23
|
-
|
|
24
|
-
|
|
53
|
+
# Serializes publish and seed across processes. Any stable 63-bit integer;
|
|
54
|
+
# this one spells "nondisp" in ASCII, so it is easy to spot in pg_locks.
|
|
55
|
+
PUBLISH_LOCK_ID = 0x6e6f6e64697370
|
|
56
|
+
PUBLISH_LOCK_WAIT = 30
|
|
25
57
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
raise "The list is empty. This might indicate a problem with the format." if downloaded_domains.empty?
|
|
58
|
+
INSERT_BATCH_SIZE = 5_000
|
|
59
|
+
LOG_PREVIEW = 20
|
|
29
60
|
|
|
30
|
-
|
|
61
|
+
PROCESS_LOCK = Monitor.new
|
|
62
|
+
private_constant :PROCESS_LOCK
|
|
31
63
|
|
|
32
|
-
|
|
33
|
-
|
|
64
|
+
class << self
|
|
65
|
+
# Returns true when every source was refreshed and the list published.
|
|
66
|
+
# Never raises: a failure is logged, and an empty table is seeded from
|
|
67
|
+
# the bundled snapshot so a broken network never means no protection.
|
|
68
|
+
def update
|
|
69
|
+
log :info, 'Refreshing list of disposable domains...'
|
|
70
|
+
|
|
71
|
+
if SourceSnapshot.available?
|
|
72
|
+
update_from_sources
|
|
34
73
|
else
|
|
35
|
-
|
|
36
|
-
seed
|
|
37
|
-
false
|
|
74
|
+
update_single_source
|
|
38
75
|
end
|
|
39
|
-
rescue
|
|
40
|
-
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
log :error, "An error occurred when trying to update the list of disposable domains: #{e.class}: #{e.message}"
|
|
41
78
|
seed
|
|
42
79
|
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Rebuilds the stored list from each configured source's last good
|
|
83
|
+
# snapshot, without fetching anything. Run it after changing
|
|
84
|
+
# excluded_domains, additional_domains, block_privacy_relays or sources,
|
|
85
|
+
# or after a rollback. Returns true when it published.
|
|
86
|
+
def publish
|
|
87
|
+
with_publish_lock { publish! } || false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Withdraws a source's newest good snapshot (marking it `rolled_back`, so
|
|
91
|
+
# the same payload is refused if upstream serves it again) and republishes
|
|
92
|
+
# from the one before. The emergency brake for a poisoned upstream; a
|
|
93
|
+
# permanent fix is excluding the domain, or removing the source.
|
|
94
|
+
#
|
|
95
|
+
# If nothing good is left for any primary, the list bundled with the gem
|
|
96
|
+
# stands in, even over a populated table: a rollback must always end
|
|
97
|
+
# with the withdrawn list unpublished.
|
|
98
|
+
def rollback(source_name)
|
|
99
|
+
with_publish_lock do
|
|
100
|
+
# Chosen INSIDE the lock: a refresh accepted between choosing and
|
|
101
|
+
# locking would otherwise survive the rollback it raced.
|
|
102
|
+
snapshot = SourceSnapshot.last_good(source_name.to_s)
|
|
103
|
+
unless snapshot
|
|
104
|
+
log :error, "Nothing to roll back: source #{source_name} has no good snapshot."
|
|
105
|
+
next false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
snapshot.update!(status: 'rolled_back')
|
|
109
|
+
log :warn, "Rolled back #{source_name} snapshot ##{snapshot.id} (#{snapshot.domain_count} domains, fetched #{snapshot.fetched_at})."
|
|
110
|
+
# A withdrawn source with no older good snapshot is simply left out:
|
|
111
|
+
# keeping its poisoned list published would defeat the rollback.
|
|
112
|
+
publish!(withdrawn: source_name.to_s)
|
|
113
|
+
end || false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Accepts that a source really did shrink: resets its high-water mark to
|
|
117
|
+
# its current good snapshot, so the shrink guard measures from there.
|
|
118
|
+
# A human decision by design; nothing calls it automatically. The count
|
|
119
|
+
# is recomputed from that snapshot's payload, never copied from a
|
|
120
|
+
# stored figure that an older rule may have produced.
|
|
121
|
+
def reset_baseline(source_name)
|
|
122
|
+
with_publish_lock do
|
|
123
|
+
snapshot = SourceSnapshot.last_good(source_name.to_s)
|
|
124
|
+
next false unless snapshot
|
|
125
|
+
|
|
126
|
+
count = storable_count(snapshot.domain_list)
|
|
127
|
+
attributes = { domain_count: count }
|
|
128
|
+
attributes[:high_water_count] = count if high_water_column?
|
|
129
|
+
snapshot.update!(attributes)
|
|
130
|
+
log :warn, "Reset the #{source_name} shrink baseline to #{count} domains."
|
|
131
|
+
true
|
|
132
|
+
end || false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Seeds the domain table from the blocklist snapshot bundled with the gem,
|
|
136
|
+
# but ONLY when the table is empty. This closes the fresh-install fail-open
|
|
137
|
+
# window where every email passed validation until the first successful
|
|
138
|
+
# remote update. Returns true if it seeded, false otherwise.
|
|
139
|
+
#
|
|
140
|
+
# The bundled list is a snapshot from the gem's release date — always run
|
|
141
|
+
# DomainListUpdater.update (e.g. via the provided job) to stay current.
|
|
142
|
+
def seed
|
|
143
|
+
with_publish_lock { seed! } || false
|
|
43
144
|
rescue StandardError => e
|
|
44
|
-
|
|
45
|
-
seed
|
|
145
|
+
log :error, "Could not seed from the bundled list: #{e.message}"
|
|
46
146
|
false
|
|
47
147
|
end
|
|
48
|
-
end
|
|
49
148
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
# DomainListUpdater.update (e.g. via the provided job) to stay current.
|
|
57
|
-
def self.seed
|
|
58
|
-
return false unless Nondisposable::DisposableDomain.none?
|
|
59
|
-
|
|
60
|
-
Rails.logger.info "[nondisposable] Domain table is empty; seeding from the blocklist bundled with the gem..."
|
|
61
|
-
count = replace_domains(bundled_domains)
|
|
62
|
-
Rails.logger.info "[nondisposable] Seeded #{count} disposable domains from the bundled snapshot. Run Nondisposable::DomainListUpdater.update to fetch the latest list."
|
|
63
|
-
true
|
|
64
|
-
rescue StandardError => e
|
|
65
|
-
Rails.logger.error "[nondisposable] Could not seed from the bundled list: #{e.message}"
|
|
66
|
-
false
|
|
67
|
-
end
|
|
149
|
+
# config.sources as validated Source objects. Raises ArgumentError on a
|
|
150
|
+
# configuration we cannot honour, so a typo fails loudly in the log
|
|
151
|
+
# instead of silently shrinking the list.
|
|
152
|
+
def configured_sources
|
|
153
|
+
sources = Array(Nondisposable.configuration.sources).map { |source| Source.build(source) }
|
|
154
|
+
raise ArgumentError, 'config.sources is empty' if sources.empty?
|
|
68
155
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
Net::HTTP.start(
|
|
77
|
-
uri.host,
|
|
78
|
-
uri.port,
|
|
79
|
-
use_ssl: uri.scheme == 'https',
|
|
80
|
-
open_timeout: OPEN_TIMEOUT,
|
|
81
|
-
read_timeout: READ_TIMEOUT
|
|
82
|
-
) do |http|
|
|
83
|
-
http.get(uri.request_uri)
|
|
156
|
+
[[:name, 'name'], [:url, 'URL']].each do |attribute, label|
|
|
157
|
+
dupes = sources.map(&attribute).tally.select { |_, count| count > 1 }.keys
|
|
158
|
+
raise ArgumentError, "config.sources lists the same #{label} twice: #{dupes.join(', ')}" if dupes.any?
|
|
159
|
+
end
|
|
160
|
+
raise ArgumentError, 'config.sources needs at least one :primary source' if sources.none?(&:primary?)
|
|
161
|
+
|
|
162
|
+
sources
|
|
84
163
|
end
|
|
85
|
-
end
|
|
86
|
-
private_class_method :fetch_list
|
|
87
164
|
|
|
88
|
-
|
|
89
|
-
# merged with `additional_domains` and minus `excluded_domains`.
|
|
90
|
-
# Reports (instead of silently swallowing) any rows the bulk insert skipped.
|
|
91
|
-
def self.replace_domains(raw_domains)
|
|
92
|
-
domains = normalize(raw_domains + Nondisposable.configuration.additional_domains)
|
|
93
|
-
domains -= normalize(Nondisposable.configuration.excluded_domains)
|
|
165
|
+
private
|
|
94
166
|
|
|
95
|
-
|
|
167
|
+
# ---- Multi-source path ------------------------------------------------
|
|
96
168
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
169
|
+
def update_from_sources
|
|
170
|
+
sources = configured_sources
|
|
171
|
+
refreshed = sources.map { |source| refresh(source) }.all?
|
|
172
|
+
published = publish
|
|
173
|
+
refreshed && published
|
|
174
|
+
end
|
|
100
175
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
176
|
+
# Fetches one source into a snapshot row. Never raises.
|
|
177
|
+
#
|
|
178
|
+
# The download happens outside the publish lock (it can take a while);
|
|
179
|
+
# judging it and recording the verdict happen inside, so a rollback or a
|
|
180
|
+
# publish can never interleave with a snapshot being accepted.
|
|
181
|
+
def refresh(source)
|
|
182
|
+
fetched_at = Time.current
|
|
183
|
+
parsed = Source.parse(SourceFetcher.fetch(source))
|
|
184
|
+
with_publish_lock { accept(source, parsed) } || false
|
|
185
|
+
rescue StandardError => e
|
|
186
|
+
log :error, "Could not fetch #{source.name} (#{source.url}): #{e.class}: #{e.message}. Keeping its last good snapshot."
|
|
187
|
+
begin
|
|
188
|
+
record(source, 'failed', fetched_at || Time.current, error: "#{e.class}: #{e.message}"[0, 1_000])
|
|
189
|
+
rescue StandardError => record_error
|
|
190
|
+
log :error, "Could not record the failed #{source.name} fetch: #{record_error.message}"
|
|
191
|
+
end
|
|
192
|
+
false
|
|
193
|
+
ensure
|
|
194
|
+
begin
|
|
195
|
+
with_publish_lock { SourceSnapshot.prune(source.name) }
|
|
196
|
+
rescue StandardError => e
|
|
197
|
+
log :warn, "Could not prune old #{source.name} snapshots: #{e.message}"
|
|
198
|
+
end
|
|
104
199
|
end
|
|
105
200
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
201
|
+
# `fetched_at` is the ACCEPTANCE time, taken under the lock: snapshots
|
|
202
|
+
# are ordered by when they were judged, so a slow job that finishes late
|
|
203
|
+
# can never slot an older verdict above a newer one.
|
|
204
|
+
def accept(source, parsed)
|
|
205
|
+
fetched_at = Time.current
|
|
206
|
+
sha256 = Digest::SHA256.hexdigest(parsed.domains.join("\n"))
|
|
207
|
+
count = storable_count(parsed.domains)
|
|
208
|
+
previous = SourceSnapshot.last_good(source.name)
|
|
209
|
+
|
|
210
|
+
if (reason = guard(source, parsed, count, sha256, previous))
|
|
211
|
+
log :error, "Refusing the new #{source.name} list: #{reason}. Keeping its last good snapshot."
|
|
212
|
+
record(source, 'rejected', fetched_at, sha256: sha256, domain_count: count, error: reason)
|
|
213
|
+
return false
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
if previous && previous.sha256 == sha256
|
|
217
|
+
previous.update!(fetched_at: [previous.fetched_at, fetched_at].max)
|
|
218
|
+
log :info, "#{source.name}: unchanged (#{count} domains)."
|
|
219
|
+
return true
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
log_changes(source, previous, parsed)
|
|
223
|
+
log_poison(source, parsed.domains)
|
|
224
|
+
attributes = { sha256: sha256, domain_count: count, domains: parsed.domains.join("\n") }
|
|
225
|
+
attributes[:high_water_count] = [high_water(source, previous), count].compact.max if high_water_column?
|
|
226
|
+
record(source, 'ok', fetched_at, **attributes)
|
|
227
|
+
true
|
|
112
228
|
end
|
|
113
229
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
230
|
+
# A snapshots table created by an earlier development build of this
|
|
231
|
+
# branch has no high_water_count. Never crash a refresh over it: fall
|
|
232
|
+
# back to the retained snapshots, and say so once per process.
|
|
233
|
+
def high_water_column?
|
|
234
|
+
present = SourceSnapshot.column_names.include?('high_water_count')
|
|
235
|
+
unless present || @warned_high_water
|
|
236
|
+
@warned_high_water = true
|
|
237
|
+
log :warn, 'nondisposable_source_snapshots has no high_water_count column; the shrink guard falls back to ' \
|
|
238
|
+
'retained snapshots. Recreate the table from the current migration to restore it.'
|
|
239
|
+
end
|
|
240
|
+
present
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# The domains of a list that would be stored under ANY host policy: not
|
|
244
|
+
# junk, not a public suffix, not a known real provider, not a privacy
|
|
245
|
+
# relay. Host settings (block_privacy_relays, excluded_domains,
|
|
246
|
+
# additional_domains) are deliberately ignored, so every count of a source
|
|
247
|
+
# is comparable with every other, before and after a policy change. Every
|
|
248
|
+
# size guard counts THESE, so a payload padded with anything the filter
|
|
249
|
+
# can drop cannot pass for a full list.
|
|
250
|
+
def storable_count(domains)
|
|
251
|
+
Precedence.filter(domains, host_policy: false).kept.size
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# The largest storable count this source has ever had accepted, kept on
|
|
255
|
+
# its newest good snapshot so pruning can never forget it. Without a
|
|
256
|
+
# recorded mark yet: the largest retained count, or the bundled list.
|
|
257
|
+
def high_water(source, previous = SourceSnapshot.last_good(source.name))
|
|
258
|
+
recorded = previous&.high_water_count if high_water_column?
|
|
259
|
+
return recorded if recorded # authoritative, and what reset_baseline lowers
|
|
260
|
+
|
|
261
|
+
retained = SourceSnapshot.ok.where(source: source.name).maximum(:domain_count)
|
|
262
|
+
[retained, bundled_count(source)].compact.max
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# nil when the snapshot may be used, otherwise why not. `count` is the
|
|
266
|
+
# storable count (see #storable_count); snapshot domain_counts are too.
|
|
267
|
+
def guard(source, parsed, count, sha256, previous)
|
|
268
|
+
return 'the list is empty' if count.zero?
|
|
269
|
+
|
|
270
|
+
if parsed.invalid_ratio > Source::MAX_INVALID_RATIO
|
|
271
|
+
return "#{parsed.invalid} of #{parsed.lines} lines are not domain names " \
|
|
272
|
+
"(e.g. #{parsed.invalid_sample.map(&:inspect).join(', ')}) — not a blocklist"
|
|
273
|
+
end
|
|
274
|
+
return "only #{count} domains (this source's minimum_count is #{source.minimum_count})" if count < source.minimum_count
|
|
275
|
+
|
|
276
|
+
if SourceSnapshot.where(source: source.name, status: 'rolled_back', sha256: sha256).exists?
|
|
277
|
+
return 'it is identical to a snapshot that was rolled back'
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
largest = high_water(source, previous)
|
|
281
|
+
if largest && count < largest * MAX_SHRINK_RATIO
|
|
282
|
+
return "it shrank to #{count} domains from #{largest} (more than #{(MAX_SHRINK_RATIO * 100).round}%)"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
baseline = previous&.domain_count || bundled_count(source)
|
|
286
|
+
if baseline && baseline >= source.minimum_count && count > baseline * MAX_GROWTH_FACTOR
|
|
287
|
+
return "it grew to #{count} domains from #{baseline} (more than #{MAX_GROWTH_FACTOR}x)"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def bundled_count(source)
|
|
294
|
+
return nil unless source.bundled_path && File.exist?(source.bundled_path)
|
|
295
|
+
|
|
296
|
+
storable_count(Source.parse(File.read(source.bundled_path)).domains)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def record(source, status, fetched_at, **attributes)
|
|
300
|
+
SourceSnapshot.create!(source: source.name, url: source.url, status: status, fetched_at: fetched_at, **attributes)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def publish!(withdrawn: nil)
|
|
304
|
+
snapshots = {}
|
|
305
|
+
missing_primaries = []
|
|
306
|
+
|
|
307
|
+
configured_sources.each do |source|
|
|
308
|
+
snapshot = SourceSnapshot.last_good(source.name)
|
|
309
|
+
if snapshot
|
|
310
|
+
snapshots[source] = snapshot.domain_list
|
|
311
|
+
elsif source.name == withdrawn
|
|
312
|
+
log :warn, "#{source.name} has no good snapshot left after the rollback; building the list without it."
|
|
313
|
+
elsif source.primary? && source.bundled_path
|
|
314
|
+
log :warn, "#{source.name} has no good snapshot yet; using the list bundled with the gem in its place."
|
|
315
|
+
snapshots[source] = Source.parse(File.read(source.bundled_path)).domains
|
|
316
|
+
elsif source.primary?
|
|
317
|
+
missing_primaries << source.name
|
|
318
|
+
else
|
|
319
|
+
log :warn, "#{source.name} has no good snapshot yet; building the list without it."
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
if missing_primaries.any?
|
|
324
|
+
log :error, "Not publishing: primary source(s) #{missing_primaries.join(', ')} have never been fetched successfully. " \
|
|
325
|
+
'Keeping the current list (seeding it from the bundled snapshot if it is empty).'
|
|
326
|
+
seed!
|
|
327
|
+
return false
|
|
328
|
+
end
|
|
118
329
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
330
|
+
if withdrawn && snapshots.keys.none?(&:primary?)
|
|
331
|
+
log :warn, "No primary source has a good snapshot after rolling back #{withdrawn}; " \
|
|
332
|
+
'publishing the list bundled with the gem in its place.'
|
|
333
|
+
snapshots[Sources::DISPOSABLE_EMAIL_DOMAINS.with(name: 'bundled-fallback')] = Source.parse(bundled_domains.join("\n")).domains
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
merged = SourceMerge.call(snapshots, threshold: Nondisposable.configuration.corroboration_threshold,
|
|
337
|
+
lineage: configured_sources)
|
|
338
|
+
log :info, "Merged #{snapshots.size} source(s): #{merged.primary_count} from primaries, #{merged.corroborated_count} corroborated."
|
|
339
|
+
published = publish_domains(merged.domains)
|
|
340
|
+
seed! unless published
|
|
341
|
+
published
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# ---- Single-source path (no snapshots table) ------------------------
|
|
345
|
+
|
|
346
|
+
def update_single_source
|
|
347
|
+
if sources_customised?
|
|
348
|
+
log :warn, 'config.sources is customised but the nondisposable_source_snapshots table does not exist yet. ' \
|
|
349
|
+
'Only the default list is used until you run `rails g nondisposable:upgrade` and `rails db:migrate`.'
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
parsed = Source.parse(SourceFetcher.fetch(Sources::DISPOSABLE_EMAIL_DOMAINS))
|
|
353
|
+
raise 'The list is empty. This might indicate a problem with the format.' if parsed.domains.empty?
|
|
354
|
+
if parsed.invalid_ratio > Source::MAX_INVALID_RATIO
|
|
355
|
+
raise "#{parsed.invalid} of #{parsed.lines} lines are not domain names (e.g. #{parsed.invalid_sample.map(&:inspect).join(', ')})"
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
log :info, 'Downloaded list of disposable domains...'
|
|
359
|
+
published = with_publish_lock { publish_domains(parsed.domains) }
|
|
360
|
+
raise 'Could not publish the list' unless published
|
|
361
|
+
|
|
362
|
+
true
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def sources_customised?
|
|
366
|
+
Array(Nondisposable.configuration.sources).map { |source| Source.build(source) } != Sources::DEFAULT
|
|
367
|
+
rescue ArgumentError
|
|
368
|
+
true
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# ---- Seeding and writing ----------------------------------------------
|
|
372
|
+
|
|
373
|
+
def seed!
|
|
374
|
+
return false unless DisposableDomain.none?
|
|
375
|
+
|
|
376
|
+
log :info, 'Domain table is empty; seeding from the blocklist bundled with the gem...'
|
|
377
|
+
domains = Source.parse(bundled_domains.join("\n")).domains
|
|
378
|
+
return false unless publish_domains(domains)
|
|
379
|
+
|
|
380
|
+
log :info, "Seeded #{DisposableDomain.count} disposable domains from the bundled snapshot. Run Nondisposable::DomainListUpdater.update to fetch the latest list."
|
|
381
|
+
true
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def bundled_domains
|
|
385
|
+
File.readlines(BUNDLED_LIST_PATH, chomp: true)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Applies the precedence table and writes the result. Callers hold the
|
|
389
|
+
# publish lock.
|
|
390
|
+
def publish_domains(domains)
|
|
391
|
+
result = Precedence.filter(domains)
|
|
392
|
+
log_dropped(result)
|
|
393
|
+
|
|
394
|
+
if result.kept.empty?
|
|
395
|
+
log :error, 'Refusing to publish an empty list of disposable domains. Keeping the current one.'
|
|
396
|
+
return false
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
write_domains(result.kept | legacy_spellings(domains, result.kept))
|
|
400
|
+
true
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# The unicode spelling of every kept internationalised domain that
|
|
404
|
+
# arrived in unicode, stored ALONGSIDE its canonical punycode row.
|
|
405
|
+
#
|
|
406
|
+
# Readers since 0.5.0 look up the punycode form (and match either row);
|
|
407
|
+
# 0.4.0 looked up exactly what the user typed, lowercased. Storing both
|
|
408
|
+
# keeps a mixed-version deploy, or a rollback to 0.4.0, from silently
|
|
409
|
+
# unblocking `bücher.de`. Every upstream list already ships IDNs as
|
|
410
|
+
# `xn--`, so in practice this only adds rows for host `additional_domains`
|
|
411
|
+
# typed in unicode.
|
|
412
|
+
def legacy_spellings(domains, kept)
|
|
413
|
+
raw = domains.to_a + Array(Nondisposable.configuration.additional_domains)
|
|
414
|
+
raw.each_with_object(Set.new) do |entry, spellings|
|
|
415
|
+
next if entry.to_s.ascii_only?
|
|
416
|
+
|
|
417
|
+
spelling = Domain.unicode(entry)
|
|
418
|
+
next if spelling.ascii_only? || spelling.encoding == Encoding::BINARY
|
|
419
|
+
|
|
420
|
+
spellings << spelling if kept.include?(Domain.normalize(entry))
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Replaces the stored list with `domains` in one transaction, writing only
|
|
425
|
+
# the difference: readers see the old list or the new one, never a gap.
|
|
426
|
+
# Reports (instead of silently swallowing) any rows the bulk insert skipped.
|
|
427
|
+
#
|
|
428
|
+
# The current list is read INSIDE the transaction, so the diff is taken
|
|
429
|
+
# against what this transaction will overwrite. On SQLite a concurrent
|
|
430
|
+
# writer then makes this one fail (SQLITE_BUSY) instead of merging two
|
|
431
|
+
# stale diffs; on PostgreSQL/MySQL the publish lock already serializes us.
|
|
432
|
+
def write_domains(domains)
|
|
433
|
+
current = added = removed = nil
|
|
434
|
+
|
|
435
|
+
ActiveRecord::Base.transaction do
|
|
436
|
+
current = DisposableDomain.pluck(:name).to_set
|
|
437
|
+
added = (domains - current).to_a.sort
|
|
438
|
+
removed = (current - domains).to_a.sort
|
|
439
|
+
log :info, 'Updating disposable domains...'
|
|
440
|
+
removed.each_slice(INSERT_BATCH_SIZE) { |batch| DisposableDomain.where(name: batch).delete_all }
|
|
441
|
+
added.each_slice(INSERT_BATCH_SIZE) do |batch|
|
|
442
|
+
DisposableDomain.insert_all(batch.map { |name| { name: name } }, record_timestamps: true)
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
if added.any?
|
|
447
|
+
stored = added.each_slice(INSERT_BATCH_SIZE).sum { |batch| DisposableDomain.where(name: batch).count }
|
|
448
|
+
if stored < added.size
|
|
449
|
+
skipped = added - added.each_slice(INSERT_BATCH_SIZE).flat_map { |batch| DisposableDomain.where(name: batch).pluck(:name) }
|
|
450
|
+
log :warn, "Skipped #{added.size - stored} row(s) during bulk insert (duplicate or conflicting values): #{preview(skipped)}"
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
total = DisposableDomain.count
|
|
455
|
+
log :info, "Finished updating disposable domains. Total domains: #{total} (+#{added.size} -#{removed.size})."
|
|
456
|
+
log :info, "Added: #{preview(added)}" if added.any? && current.any?
|
|
457
|
+
log :info, "Removed: #{preview(removed)}" if removed.any?
|
|
458
|
+
total
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# ---- Locking ------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
# Database-wide where the adapter has advisory locks (PostgreSQL, MySQL),
|
|
464
|
+
# process-wide otherwise. SQLite needs no more: it allows one writer at a
|
|
465
|
+
# time, and two processes publishing the same snapshots write the same
|
|
466
|
+
# rows. Returns the block's value, or nil when another process held the
|
|
467
|
+
# lock for longer than PUBLISH_LOCK_WAIT (it is publishing the same thing).
|
|
468
|
+
def with_publish_lock(&block)
|
|
469
|
+
PROCESS_LOCK.synchronize do
|
|
470
|
+
DisposableDomain.connection_pool.with_connection do |connection|
|
|
471
|
+
next block.call unless connection.supports_advisory_locks?
|
|
472
|
+
|
|
473
|
+
acquired = false
|
|
474
|
+
deadline = monotonic + PUBLISH_LOCK_WAIT
|
|
475
|
+
until (acquired = connection.get_advisory_lock(PUBLISH_LOCK_ID))
|
|
476
|
+
if monotonic > deadline
|
|
477
|
+
log :warn, "Another process has been publishing for over #{PUBLISH_LOCK_WAIT}s; skipping this publish."
|
|
478
|
+
break
|
|
479
|
+
end
|
|
480
|
+
sleep 0.25
|
|
481
|
+
end
|
|
482
|
+
next nil unless acquired
|
|
483
|
+
|
|
484
|
+
begin
|
|
485
|
+
block.call
|
|
486
|
+
ensure
|
|
487
|
+
connection.release_advisory_lock(PUBLISH_LOCK_ID)
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def monotonic
|
|
494
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# ---- Logging ------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
def log_changes(source, previous, parsed)
|
|
500
|
+
before = previous ? previous.domain_list.to_set : Set.new
|
|
501
|
+
after = parsed.domains.to_set
|
|
502
|
+
added = (after - before).to_a.sort
|
|
503
|
+
removed = (before - after).to_a.sort
|
|
504
|
+
message = "#{source.name}: #{after.size} domains (+#{added.size} -#{removed.size})"
|
|
505
|
+
message += ", #{parsed.invalid} unparseable line(s) dropped" if parsed.invalid.positive?
|
|
506
|
+
log :info, message
|
|
507
|
+
return unless previous
|
|
508
|
+
|
|
509
|
+
log :info, "#{source.name} added: #{preview(added)}" if added.any?
|
|
510
|
+
log :info, "#{source.name} removed: #{preview(removed)}" if removed.any?
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# A source listing a domain we KNOW is real is either an upstream false
|
|
514
|
+
# positive or an attempt to poison the list. Either way it is dropped at
|
|
515
|
+
# publish time, and it deserves a human's attention now.
|
|
516
|
+
def log_poison(source, domains)
|
|
517
|
+
never = Precedence.never_disposable
|
|
518
|
+
poisoned = domains.select { |domain| Precedence.namespace_hit(domain, never) }
|
|
519
|
+
suffixes = domains.select { |domain| PublicSuffix.public_suffix?(domain) }
|
|
520
|
+
|
|
521
|
+
if poisoned.any?
|
|
522
|
+
log :warn, "#{source.name} lists #{poisoned.size} domain(s) known to be real providers; they will NOT be blocked " \
|
|
523
|
+
"(upstream false positive or poisoning): #{preview(poisoned)}"
|
|
524
|
+
end
|
|
525
|
+
return if suffixes.empty?
|
|
526
|
+
|
|
527
|
+
log :warn, "#{source.name} lists #{suffixes.size} public suffix(es); they will NOT be blocked: #{preview(suffixes)}"
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def log_dropped(result)
|
|
531
|
+
result.dropped_by_reason.each do |reason, domains|
|
|
532
|
+
level = %i[additional_domain_is_never_disposable invalid_additional_domain].include?(reason) ? :warn : :info
|
|
533
|
+
log level, "Not storing #{domains.size} domain(s) (#{reason}): #{preview(domains)}"
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def preview(domains)
|
|
538
|
+
text = domains.first(LOG_PREVIEW).join(', ')
|
|
539
|
+
text += ", … (#{domains.size - LOG_PREVIEW} more)" if domains.size > LOG_PREVIEW
|
|
540
|
+
text
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def log(level, message)
|
|
544
|
+
Rails.logger.public_send(level, "[nondisposable] #{message}")
|
|
545
|
+
end
|
|
123
546
|
end
|
|
124
|
-
private_class_method :normalize
|
|
125
547
|
end
|
|
126
548
|
end
|