karaoke-gen 0.101.0__py3-none-any.whl → 0.105.4__py3-none-any.whl
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.
- backend/Dockerfile.base +1 -0
- backend/api/routes/admin.py +226 -3
- backend/api/routes/audio_search.py +4 -32
- backend/api/routes/file_upload.py +18 -83
- backend/api/routes/jobs.py +2 -2
- backend/api/routes/push.py +238 -0
- backend/api/routes/rate_limits.py +428 -0
- backend/api/routes/users.py +79 -19
- backend/config.py +25 -1
- backend/exceptions.py +66 -0
- backend/main.py +26 -1
- backend/models/job.py +4 -0
- backend/models/user.py +20 -2
- backend/services/email_validation_service.py +646 -0
- backend/services/firestore_service.py +21 -0
- backend/services/gce_encoding/main.py +22 -8
- backend/services/job_defaults_service.py +113 -0
- backend/services/job_manager.py +109 -13
- backend/services/push_notification_service.py +409 -0
- backend/services/rate_limit_service.py +641 -0
- backend/services/stripe_service.py +2 -2
- backend/tests/conftest.py +8 -1
- backend/tests/test_admin_delete_outputs.py +352 -0
- backend/tests/test_audio_search.py +12 -8
- backend/tests/test_email_validation_service.py +298 -0
- backend/tests/test_file_upload.py +8 -6
- backend/tests/test_gce_encoding_worker.py +229 -0
- backend/tests/test_impersonation.py +18 -3
- backend/tests/test_made_for_you.py +6 -4
- backend/tests/test_push_notification_service.py +460 -0
- backend/tests/test_push_routes.py +357 -0
- backend/tests/test_rate_limit_service.py +396 -0
- backend/tests/test_rate_limits_api.py +392 -0
- backend/tests/test_stripe_service.py +205 -0
- backend/workers/video_worker_orchestrator.py +42 -0
- karaoke_gen/instrumental_review/static/index.html +35 -9
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/METADATA +2 -1
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/RECORD +41 -26
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Email Validation Service.
|
|
3
|
+
|
|
4
|
+
Provides email validation, normalization, and blocklist checking
|
|
5
|
+
to prevent abuse during beta enrollment and other flows.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Optional, Tuple, Set
|
|
13
|
+
|
|
14
|
+
from google.cloud import firestore
|
|
15
|
+
|
|
16
|
+
from backend.config import settings
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _mask_email(email: str) -> str:
|
|
22
|
+
"""Mask an email address for privacy-safe logging."""
|
|
23
|
+
if not email or "@" not in email:
|
|
24
|
+
return "***"
|
|
25
|
+
local, domain = email.split("@", 1)
|
|
26
|
+
if len(local) <= 2:
|
|
27
|
+
masked_local = "*" * len(local)
|
|
28
|
+
else:
|
|
29
|
+
masked_local = local[0] + "*" * (len(local) - 2) + local[-1]
|
|
30
|
+
return f"{masked_local}@{domain}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Firestore collection for blocklist data
|
|
34
|
+
BLOCKLISTS_COLLECTION = "blocklists"
|
|
35
|
+
BLOCKLIST_CONFIG_DOC = "config"
|
|
36
|
+
|
|
37
|
+
# Default disposable email domains (can be extended via admin UI)
|
|
38
|
+
DEFAULT_DISPOSABLE_DOMAINS = {
|
|
39
|
+
# Popular temporary email services
|
|
40
|
+
"10minutemail.com",
|
|
41
|
+
"10minutemail.net",
|
|
42
|
+
"guerrillamail.com",
|
|
43
|
+
"guerrillamail.org",
|
|
44
|
+
"guerrillamail.net",
|
|
45
|
+
"guerrillamail.biz",
|
|
46
|
+
"guerrillamailblock.com",
|
|
47
|
+
"tempmail.com",
|
|
48
|
+
"tempmail.net",
|
|
49
|
+
"temp-mail.org",
|
|
50
|
+
"temp-mail.io",
|
|
51
|
+
"throwawaymail.com",
|
|
52
|
+
"mailinator.com",
|
|
53
|
+
"mailinator.net",
|
|
54
|
+
"mailinator.org",
|
|
55
|
+
"mailinator.info",
|
|
56
|
+
"maildrop.cc",
|
|
57
|
+
"yopmail.com",
|
|
58
|
+
"yopmail.fr",
|
|
59
|
+
"yopmail.net",
|
|
60
|
+
"fakeinbox.com",
|
|
61
|
+
"fakemailgenerator.com",
|
|
62
|
+
"dispostable.com",
|
|
63
|
+
"getairmail.com",
|
|
64
|
+
"getnada.com",
|
|
65
|
+
"mohmal.com",
|
|
66
|
+
"trashmail.com",
|
|
67
|
+
"trashmail.net",
|
|
68
|
+
"trashmail.org",
|
|
69
|
+
"sharklasers.com",
|
|
70
|
+
"grr.la",
|
|
71
|
+
"guerrillamail.de",
|
|
72
|
+
"pokemail.net",
|
|
73
|
+
"spam4.me",
|
|
74
|
+
"spamgourmet.com",
|
|
75
|
+
"mytrashmail.com",
|
|
76
|
+
"mailnesia.com",
|
|
77
|
+
"mailcatch.com",
|
|
78
|
+
"mintemail.com",
|
|
79
|
+
"tempr.email",
|
|
80
|
+
"tempail.com",
|
|
81
|
+
"emailondeck.com",
|
|
82
|
+
"incognitomail.com",
|
|
83
|
+
"inboxalias.com",
|
|
84
|
+
"33mail.com",
|
|
85
|
+
"spamex.com",
|
|
86
|
+
"spamfree24.org",
|
|
87
|
+
"spamspot.com",
|
|
88
|
+
"mailnull.com",
|
|
89
|
+
"mailsac.com",
|
|
90
|
+
"emailfake.com",
|
|
91
|
+
"fakemail.fr",
|
|
92
|
+
"tempinbox.com",
|
|
93
|
+
"throwaway.email",
|
|
94
|
+
"burnermail.io",
|
|
95
|
+
"anonbox.net",
|
|
96
|
+
"anonymbox.com",
|
|
97
|
+
"discard.email",
|
|
98
|
+
"discardmail.com",
|
|
99
|
+
"discardmail.de",
|
|
100
|
+
"mailexpire.com",
|
|
101
|
+
"mailforspam.com",
|
|
102
|
+
"meltmail.com",
|
|
103
|
+
"mt2009.com",
|
|
104
|
+
"mt2014.com",
|
|
105
|
+
"nospam.ze.tc",
|
|
106
|
+
"nospamfor.us",
|
|
107
|
+
"nowmymail.com",
|
|
108
|
+
"receiveee.com",
|
|
109
|
+
"safe-mail.net",
|
|
110
|
+
"spamavert.com",
|
|
111
|
+
"spambob.com",
|
|
112
|
+
"spambog.com",
|
|
113
|
+
"spambox.us",
|
|
114
|
+
"spamcannon.com",
|
|
115
|
+
"spamcannon.net",
|
|
116
|
+
"spamcon.org",
|
|
117
|
+
"spamcorptastic.com",
|
|
118
|
+
"spamday.com",
|
|
119
|
+
"spamfree.eu",
|
|
120
|
+
"spamherelots.com",
|
|
121
|
+
"spamhereplease.com",
|
|
122
|
+
"spamhole.com",
|
|
123
|
+
"spamify.com",
|
|
124
|
+
"spaminator.de",
|
|
125
|
+
"spamkill.info",
|
|
126
|
+
"spaml.com",
|
|
127
|
+
"spaml.de",
|
|
128
|
+
"spamoff.de",
|
|
129
|
+
"spamobox.com",
|
|
130
|
+
"spamslicer.com",
|
|
131
|
+
"spamstack.net",
|
|
132
|
+
"spamthis.co.uk",
|
|
133
|
+
"spamthisplease.com",
|
|
134
|
+
"supergreatmail.com",
|
|
135
|
+
"suremail.info",
|
|
136
|
+
"teleworm.us",
|
|
137
|
+
"tempemail.co.za",
|
|
138
|
+
"tempemail.net",
|
|
139
|
+
"tempmailaddress.com",
|
|
140
|
+
"tempmailo.com",
|
|
141
|
+
"thankyou2010.com",
|
|
142
|
+
"thisisnotmyrealemail.com",
|
|
143
|
+
"tm.slsrs.ru",
|
|
144
|
+
"tmpeml.info",
|
|
145
|
+
"trash-mail.at",
|
|
146
|
+
"trash-mail.de",
|
|
147
|
+
"trash2009.com",
|
|
148
|
+
"trashemail.de",
|
|
149
|
+
"trashmail.at",
|
|
150
|
+
"trashmailer.com",
|
|
151
|
+
"wegwerfmail.de",
|
|
152
|
+
"wegwerfmail.net",
|
|
153
|
+
"wegwerfmail.org",
|
|
154
|
+
"wh4f.org",
|
|
155
|
+
"willhackforfood.biz",
|
|
156
|
+
"willselfdestruct.com",
|
|
157
|
+
"xmaily.com",
|
|
158
|
+
"xyzfree.net",
|
|
159
|
+
"yep.it",
|
|
160
|
+
"yogamaven.com",
|
|
161
|
+
"yuurok.com",
|
|
162
|
+
"zehnminutenmail.de",
|
|
163
|
+
"zippymail.info",
|
|
164
|
+
# Additional common ones
|
|
165
|
+
"mailnator.com",
|
|
166
|
+
"bugmenot.com",
|
|
167
|
+
"dodgeit.com",
|
|
168
|
+
"dodgit.com",
|
|
169
|
+
"e4ward.com",
|
|
170
|
+
"emailsensei.com",
|
|
171
|
+
"hushmail.com",
|
|
172
|
+
"jetable.org",
|
|
173
|
+
"kasmail.com",
|
|
174
|
+
"mailblock.net",
|
|
175
|
+
"mailcatch.com",
|
|
176
|
+
"mymailoasis.com",
|
|
177
|
+
"nervmich.net",
|
|
178
|
+
"nervtmansen.de",
|
|
179
|
+
"oneoffemail.com",
|
|
180
|
+
"pookmail.com",
|
|
181
|
+
"shortmail.net",
|
|
182
|
+
"sneakemail.com",
|
|
183
|
+
"sogetthis.com",
|
|
184
|
+
"tempemailaddress.com",
|
|
185
|
+
"tempomail.fr",
|
|
186
|
+
"temporaryemail.net",
|
|
187
|
+
"temporaryforwarding.com",
|
|
188
|
+
"temporaryinbox.com",
|
|
189
|
+
"thankyou2010.com",
|
|
190
|
+
"tyldd.com",
|
|
191
|
+
"uggsrock.com",
|
|
192
|
+
"veryrealemail.com",
|
|
193
|
+
"yourewronghereswhy.com",
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# Gmail-like domains that support alias normalization
|
|
197
|
+
GMAIL_LIKE_DOMAINS = {
|
|
198
|
+
"gmail.com",
|
|
199
|
+
"googlemail.com",
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class EmailValidationService:
|
|
204
|
+
"""
|
|
205
|
+
Service for email validation and abuse prevention.
|
|
206
|
+
|
|
207
|
+
Features:
|
|
208
|
+
- Email normalization (Gmail alias stripping)
|
|
209
|
+
- Disposable domain detection
|
|
210
|
+
- Blocklist checking (emails, IPs)
|
|
211
|
+
- IP-based enrollment rate limiting
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
_instance: Optional["EmailValidationService"] = None
|
|
215
|
+
_blocklist_cache: Optional[dict] = None
|
|
216
|
+
_blocklist_cache_time: Optional[datetime] = None
|
|
217
|
+
_cache_ttl_seconds: int = 300 # 5 minutes
|
|
218
|
+
|
|
219
|
+
def __init__(self, db: Optional[firestore.Client] = None):
|
|
220
|
+
"""Initialize the email validation service."""
|
|
221
|
+
if db is None:
|
|
222
|
+
from backend.services.firestore_service import get_firestore_client
|
|
223
|
+
db = get_firestore_client()
|
|
224
|
+
self.db = db
|
|
225
|
+
|
|
226
|
+
@classmethod
|
|
227
|
+
def get_instance(cls, db: Optional[firestore.Client] = None) -> "EmailValidationService":
|
|
228
|
+
"""Get singleton instance of the service."""
|
|
229
|
+
if cls._instance is None:
|
|
230
|
+
cls._instance = cls(db)
|
|
231
|
+
return cls._instance
|
|
232
|
+
|
|
233
|
+
def normalize_email(self, email: str) -> str:
|
|
234
|
+
"""
|
|
235
|
+
Normalize an email address.
|
|
236
|
+
|
|
237
|
+
For Gmail-like domains:
|
|
238
|
+
- Removes dots from local part
|
|
239
|
+
- Removes everything after + in local part
|
|
240
|
+
- Converts to lowercase
|
|
241
|
+
|
|
242
|
+
For other domains:
|
|
243
|
+
- Only converts to lowercase
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
email: Email address to normalize
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
Normalized email address
|
|
250
|
+
|
|
251
|
+
Examples:
|
|
252
|
+
j.o.h.n+spam@gmail.com -> john@gmail.com
|
|
253
|
+
John.Doe@example.com -> john.doe@example.com
|
|
254
|
+
"""
|
|
255
|
+
if not email or "@" not in email:
|
|
256
|
+
return email.lower().strip() if email else ""
|
|
257
|
+
|
|
258
|
+
email = email.lower().strip()
|
|
259
|
+
local, domain = email.rsplit("@", 1)
|
|
260
|
+
|
|
261
|
+
if domain in GMAIL_LIKE_DOMAINS:
|
|
262
|
+
# Remove dots from local part
|
|
263
|
+
local = local.replace(".", "")
|
|
264
|
+
# Remove everything after +
|
|
265
|
+
if "+" in local:
|
|
266
|
+
local = local.split("+")[0]
|
|
267
|
+
|
|
268
|
+
return f"{local}@{domain}"
|
|
269
|
+
|
|
270
|
+
def get_blocklist_config(self, force_refresh: bool = False) -> dict:
|
|
271
|
+
"""
|
|
272
|
+
Get blocklist configuration from Firestore.
|
|
273
|
+
|
|
274
|
+
Uses caching to reduce Firestore reads.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Dict with disposable_domains, blocked_emails, blocked_ips sets
|
|
278
|
+
"""
|
|
279
|
+
now = datetime.now(timezone.utc)
|
|
280
|
+
|
|
281
|
+
# Check cache validity
|
|
282
|
+
if (
|
|
283
|
+
not force_refresh
|
|
284
|
+
and self._blocklist_cache is not None
|
|
285
|
+
and self._blocklist_cache_time is not None
|
|
286
|
+
and (now - self._blocklist_cache_time).total_seconds() < self._cache_ttl_seconds
|
|
287
|
+
):
|
|
288
|
+
return self._blocklist_cache
|
|
289
|
+
|
|
290
|
+
# Fetch from Firestore
|
|
291
|
+
doc_ref = self.db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC)
|
|
292
|
+
doc = doc_ref.get()
|
|
293
|
+
|
|
294
|
+
if doc.exists:
|
|
295
|
+
data = doc.to_dict()
|
|
296
|
+
config = {
|
|
297
|
+
"disposable_domains": set(data.get("disposable_domains", [])) | DEFAULT_DISPOSABLE_DOMAINS,
|
|
298
|
+
"blocked_emails": set(data.get("blocked_emails", [])),
|
|
299
|
+
"blocked_ips": set(data.get("blocked_ips", [])),
|
|
300
|
+
}
|
|
301
|
+
else:
|
|
302
|
+
# Use defaults if no config exists
|
|
303
|
+
config = {
|
|
304
|
+
"disposable_domains": DEFAULT_DISPOSABLE_DOMAINS.copy(),
|
|
305
|
+
"blocked_emails": set(),
|
|
306
|
+
"blocked_ips": set(),
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
# Update cache
|
|
310
|
+
EmailValidationService._blocklist_cache = config
|
|
311
|
+
EmailValidationService._blocklist_cache_time = now
|
|
312
|
+
|
|
313
|
+
return config
|
|
314
|
+
|
|
315
|
+
def is_disposable_domain(self, email: str) -> bool:
|
|
316
|
+
"""
|
|
317
|
+
Check if an email uses a disposable domain.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
email: Email address to check
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
True if the domain is known to be disposable
|
|
324
|
+
"""
|
|
325
|
+
if not email or "@" not in email:
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
domain = email.lower().split("@")[-1]
|
|
329
|
+
config = self.get_blocklist_config()
|
|
330
|
+
return domain in config["disposable_domains"]
|
|
331
|
+
|
|
332
|
+
def is_email_blocked(self, email: str) -> bool:
|
|
333
|
+
"""
|
|
334
|
+
Check if an email is explicitly blocked.
|
|
335
|
+
|
|
336
|
+
Checks both the raw email and normalized version.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
email: Email address to check
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
True if the email is blocked
|
|
343
|
+
"""
|
|
344
|
+
if not email:
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
config = self.get_blocklist_config()
|
|
348
|
+
email_lower = email.lower().strip()
|
|
349
|
+
email_normalized = self.normalize_email(email)
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
email_lower in config["blocked_emails"]
|
|
353
|
+
or email_normalized in config["blocked_emails"]
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def is_ip_blocked(self, ip_address: str) -> bool:
|
|
357
|
+
"""
|
|
358
|
+
Check if an IP address is blocked.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
ip_address: IP address to check
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
True if the IP is blocked
|
|
365
|
+
"""
|
|
366
|
+
if not ip_address:
|
|
367
|
+
return False
|
|
368
|
+
|
|
369
|
+
config = self.get_blocklist_config()
|
|
370
|
+
return ip_address in config["blocked_ips"]
|
|
371
|
+
|
|
372
|
+
def validate_email_for_beta(self, email: str) -> Tuple[bool, str]:
|
|
373
|
+
"""
|
|
374
|
+
Validate an email for beta enrollment.
|
|
375
|
+
|
|
376
|
+
Performs all validation checks:
|
|
377
|
+
1. Basic format validation
|
|
378
|
+
2. Disposable domain check
|
|
379
|
+
3. Blocked email check
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
email: Email address to validate
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Tuple of (is_valid, error_message)
|
|
386
|
+
"""
|
|
387
|
+
if not email or "@" not in email:
|
|
388
|
+
return False, "Invalid email format"
|
|
389
|
+
|
|
390
|
+
# Check disposable domain
|
|
391
|
+
if self.is_disposable_domain(email):
|
|
392
|
+
logger.warning(f"Beta enrollment blocked - disposable domain: {_mask_email(email)}")
|
|
393
|
+
return False, "Disposable email addresses are not allowed"
|
|
394
|
+
|
|
395
|
+
# Check blocked email
|
|
396
|
+
if self.is_email_blocked(email):
|
|
397
|
+
logger.warning(f"Beta enrollment blocked - email blocked: {_mask_email(email)}")
|
|
398
|
+
return False, "This email address is not allowed"
|
|
399
|
+
|
|
400
|
+
return True, ""
|
|
401
|
+
|
|
402
|
+
def hash_ip(self, ip_address: str) -> str:
|
|
403
|
+
"""
|
|
404
|
+
Hash an IP address for privacy-preserving storage.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
ip_address: IP address to hash
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
SHA-256 hash of the IP
|
|
411
|
+
"""
|
|
412
|
+
return hashlib.sha256(ip_address.encode()).hexdigest()
|
|
413
|
+
|
|
414
|
+
# -------------------------------------------------------------------------
|
|
415
|
+
# Blocklist Management (Admin)
|
|
416
|
+
# -------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
def add_disposable_domain(self, domain: str, admin_email: str) -> bool:
|
|
419
|
+
"""Add a domain to the disposable domains blocklist."""
|
|
420
|
+
domain = domain.lower().strip()
|
|
421
|
+
if not domain:
|
|
422
|
+
return False
|
|
423
|
+
|
|
424
|
+
doc_ref = self.db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC)
|
|
425
|
+
|
|
426
|
+
@firestore.transactional
|
|
427
|
+
def update_in_transaction(transaction, doc_ref):
|
|
428
|
+
doc = doc_ref.get(transaction=transaction)
|
|
429
|
+
if doc.exists:
|
|
430
|
+
data = doc.to_dict()
|
|
431
|
+
domains = set(data.get("disposable_domains", []))
|
|
432
|
+
else:
|
|
433
|
+
data = {}
|
|
434
|
+
domains = set()
|
|
435
|
+
|
|
436
|
+
domains.add(domain)
|
|
437
|
+
data["disposable_domains"] = list(domains)
|
|
438
|
+
data["updated_at"] = datetime.now(timezone.utc)
|
|
439
|
+
data["updated_by"] = admin_email
|
|
440
|
+
transaction.set(doc_ref, data, merge=True)
|
|
441
|
+
|
|
442
|
+
transaction = self.db.transaction()
|
|
443
|
+
update_in_transaction(transaction, doc_ref)
|
|
444
|
+
|
|
445
|
+
# Invalidate cache
|
|
446
|
+
EmailValidationService._blocklist_cache = None
|
|
447
|
+
|
|
448
|
+
logger.info(f"Added disposable domain: {domain} by {admin_email}")
|
|
449
|
+
return True
|
|
450
|
+
|
|
451
|
+
def remove_disposable_domain(self, domain: str, admin_email: str) -> bool:
|
|
452
|
+
"""Remove a domain from the disposable domains blocklist."""
|
|
453
|
+
domain = domain.lower().strip()
|
|
454
|
+
|
|
455
|
+
doc_ref = self.db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC)
|
|
456
|
+
result = {"found": False}
|
|
457
|
+
|
|
458
|
+
@firestore.transactional
|
|
459
|
+
def remove_in_transaction(transaction, doc_ref):
|
|
460
|
+
doc = doc_ref.get(transaction=transaction)
|
|
461
|
+
if not doc.exists:
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
data = doc.to_dict()
|
|
465
|
+
domains = set(data.get("disposable_domains", []))
|
|
466
|
+
|
|
467
|
+
if domain not in domains:
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
result["found"] = True
|
|
471
|
+
domains.discard(domain)
|
|
472
|
+
data["disposable_domains"] = list(domains)
|
|
473
|
+
data["updated_at"] = datetime.now(timezone.utc)
|
|
474
|
+
data["updated_by"] = admin_email
|
|
475
|
+
transaction.set(doc_ref, data, merge=True)
|
|
476
|
+
|
|
477
|
+
transaction = self.db.transaction()
|
|
478
|
+
remove_in_transaction(transaction, doc_ref)
|
|
479
|
+
|
|
480
|
+
if not result["found"]:
|
|
481
|
+
return False
|
|
482
|
+
|
|
483
|
+
# Invalidate cache
|
|
484
|
+
EmailValidationService._blocklist_cache = None
|
|
485
|
+
|
|
486
|
+
logger.info(f"Removed disposable domain: {domain} by {admin_email}")
|
|
487
|
+
return True
|
|
488
|
+
|
|
489
|
+
def add_blocked_email(self, email: str, admin_email: str) -> bool:
|
|
490
|
+
"""Add an email to the blocked emails list."""
|
|
491
|
+
email = email.lower().strip()
|
|
492
|
+
if not email:
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
doc_ref = self.db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC)
|
|
496
|
+
|
|
497
|
+
@firestore.transactional
|
|
498
|
+
def update_in_transaction(transaction, doc_ref):
|
|
499
|
+
doc = doc_ref.get(transaction=transaction)
|
|
500
|
+
if doc.exists:
|
|
501
|
+
data = doc.to_dict()
|
|
502
|
+
emails = set(data.get("blocked_emails", []))
|
|
503
|
+
else:
|
|
504
|
+
data = {}
|
|
505
|
+
emails = set()
|
|
506
|
+
|
|
507
|
+
emails.add(email)
|
|
508
|
+
data["blocked_emails"] = list(emails)
|
|
509
|
+
data["updated_at"] = datetime.now(timezone.utc)
|
|
510
|
+
data["updated_by"] = admin_email
|
|
511
|
+
transaction.set(doc_ref, data, merge=True)
|
|
512
|
+
|
|
513
|
+
transaction = self.db.transaction()
|
|
514
|
+
update_in_transaction(transaction, doc_ref)
|
|
515
|
+
|
|
516
|
+
# Invalidate cache
|
|
517
|
+
EmailValidationService._blocklist_cache = None
|
|
518
|
+
|
|
519
|
+
logger.info(f"Added blocked email: {email} by {admin_email}")
|
|
520
|
+
return True
|
|
521
|
+
|
|
522
|
+
def remove_blocked_email(self, email: str, admin_email: str) -> bool:
|
|
523
|
+
"""Remove an email from the blocked emails list."""
|
|
524
|
+
email = email.lower().strip()
|
|
525
|
+
|
|
526
|
+
doc_ref = self.db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC)
|
|
527
|
+
result = {"found": False}
|
|
528
|
+
|
|
529
|
+
@firestore.transactional
|
|
530
|
+
def remove_in_transaction(transaction, doc_ref):
|
|
531
|
+
doc = doc_ref.get(transaction=transaction)
|
|
532
|
+
if not doc.exists:
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
data = doc.to_dict()
|
|
536
|
+
emails = set(data.get("blocked_emails", []))
|
|
537
|
+
|
|
538
|
+
if email not in emails:
|
|
539
|
+
return
|
|
540
|
+
|
|
541
|
+
result["found"] = True
|
|
542
|
+
emails.discard(email)
|
|
543
|
+
data["blocked_emails"] = list(emails)
|
|
544
|
+
data["updated_at"] = datetime.now(timezone.utc)
|
|
545
|
+
data["updated_by"] = admin_email
|
|
546
|
+
transaction.set(doc_ref, data, merge=True)
|
|
547
|
+
|
|
548
|
+
transaction = self.db.transaction()
|
|
549
|
+
remove_in_transaction(transaction, doc_ref)
|
|
550
|
+
|
|
551
|
+
if not result["found"]:
|
|
552
|
+
return False
|
|
553
|
+
|
|
554
|
+
# Invalidate cache
|
|
555
|
+
EmailValidationService._blocklist_cache = None
|
|
556
|
+
|
|
557
|
+
logger.info(f"Removed blocked email: {email} by {admin_email}")
|
|
558
|
+
return True
|
|
559
|
+
|
|
560
|
+
def add_blocked_ip(self, ip_address: str, admin_email: str) -> bool:
|
|
561
|
+
"""Add an IP address to the blocked IPs list."""
|
|
562
|
+
ip_address = ip_address.strip()
|
|
563
|
+
if not ip_address:
|
|
564
|
+
return False
|
|
565
|
+
|
|
566
|
+
doc_ref = self.db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC)
|
|
567
|
+
|
|
568
|
+
@firestore.transactional
|
|
569
|
+
def update_in_transaction(transaction, doc_ref):
|
|
570
|
+
doc = doc_ref.get(transaction=transaction)
|
|
571
|
+
if doc.exists:
|
|
572
|
+
data = doc.to_dict()
|
|
573
|
+
ips = set(data.get("blocked_ips", []))
|
|
574
|
+
else:
|
|
575
|
+
data = {}
|
|
576
|
+
ips = set()
|
|
577
|
+
|
|
578
|
+
ips.add(ip_address)
|
|
579
|
+
data["blocked_ips"] = list(ips)
|
|
580
|
+
data["updated_at"] = datetime.now(timezone.utc)
|
|
581
|
+
data["updated_by"] = admin_email
|
|
582
|
+
transaction.set(doc_ref, data, merge=True)
|
|
583
|
+
|
|
584
|
+
transaction = self.db.transaction()
|
|
585
|
+
update_in_transaction(transaction, doc_ref)
|
|
586
|
+
|
|
587
|
+
# Invalidate cache
|
|
588
|
+
EmailValidationService._blocklist_cache = None
|
|
589
|
+
|
|
590
|
+
logger.info(f"Added blocked IP: {ip_address} by {admin_email}")
|
|
591
|
+
return True
|
|
592
|
+
|
|
593
|
+
def remove_blocked_ip(self, ip_address: str, admin_email: str) -> bool:
|
|
594
|
+
"""Remove an IP address from the blocked IPs list."""
|
|
595
|
+
ip_address = ip_address.strip()
|
|
596
|
+
|
|
597
|
+
doc_ref = self.db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC)
|
|
598
|
+
result = {"found": False}
|
|
599
|
+
|
|
600
|
+
@firestore.transactional
|
|
601
|
+
def remove_in_transaction(transaction, doc_ref):
|
|
602
|
+
doc = doc_ref.get(transaction=transaction)
|
|
603
|
+
if not doc.exists:
|
|
604
|
+
return
|
|
605
|
+
|
|
606
|
+
data = doc.to_dict()
|
|
607
|
+
ips = set(data.get("blocked_ips", []))
|
|
608
|
+
|
|
609
|
+
if ip_address not in ips:
|
|
610
|
+
return
|
|
611
|
+
|
|
612
|
+
result["found"] = True
|
|
613
|
+
ips.discard(ip_address)
|
|
614
|
+
data["blocked_ips"] = list(ips)
|
|
615
|
+
data["updated_at"] = datetime.now(timezone.utc)
|
|
616
|
+
data["updated_by"] = admin_email
|
|
617
|
+
transaction.set(doc_ref, data, merge=True)
|
|
618
|
+
|
|
619
|
+
transaction = self.db.transaction()
|
|
620
|
+
remove_in_transaction(transaction, doc_ref)
|
|
621
|
+
|
|
622
|
+
if not result["found"]:
|
|
623
|
+
return False
|
|
624
|
+
|
|
625
|
+
# Invalidate cache
|
|
626
|
+
EmailValidationService._blocklist_cache = None
|
|
627
|
+
|
|
628
|
+
logger.info(f"Removed blocked IP: {ip_address} by {admin_email}")
|
|
629
|
+
return True
|
|
630
|
+
|
|
631
|
+
def get_blocklist_stats(self) -> dict:
|
|
632
|
+
"""Get statistics about current blocklists."""
|
|
633
|
+
config = self.get_blocklist_config(force_refresh=True)
|
|
634
|
+
return {
|
|
635
|
+
"disposable_domains_count": len(config["disposable_domains"]),
|
|
636
|
+
"blocked_emails_count": len(config["blocked_emails"]),
|
|
637
|
+
"blocked_ips_count": len(config["blocked_ips"]),
|
|
638
|
+
"default_disposable_domains_count": len(DEFAULT_DISPOSABLE_DOMAINS),
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def get_email_validation_service(
|
|
643
|
+
db: Optional[firestore.Client] = None,
|
|
644
|
+
) -> EmailValidationService:
|
|
645
|
+
"""Get the singleton EmailValidationService instance."""
|
|
646
|
+
return EmailValidationService.get_instance(db)
|
|
@@ -516,3 +516,24 @@ class FirestoreService:
|
|
|
516
516
|
logger.error(f"Error listing tokens: {e}")
|
|
517
517
|
return []
|
|
518
518
|
|
|
519
|
+
|
|
520
|
+
# Singleton client instance
|
|
521
|
+
_firestore_client: Optional[firestore.Client] = None
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def get_firestore_client() -> firestore.Client:
|
|
525
|
+
"""
|
|
526
|
+
Get a shared Firestore client instance.
|
|
527
|
+
|
|
528
|
+
This returns a raw Firestore client (not the FirestoreService) for use
|
|
529
|
+
in services that need direct Firestore access without the job-specific
|
|
530
|
+
abstractions provided by FirestoreService.
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Firestore client instance
|
|
534
|
+
"""
|
|
535
|
+
global _firestore_client
|
|
536
|
+
if _firestore_client is None:
|
|
537
|
+
_firestore_client = firestore.Client(project=settings.google_cloud_project)
|
|
538
|
+
return _firestore_client
|
|
539
|
+
|