karaoke-gen 0.101.0__py3-none-any.whl → 0.103.1__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/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/rate_limits.py +428 -0
- backend/api/routes/users.py +79 -19
- backend/config.py +16 -0
- backend/exceptions.py +66 -0
- backend/main.py +25 -1
- backend/services/email_validation_service.py +646 -0
- backend/services/firestore_service.py +21 -0
- backend/services/job_defaults_service.py +113 -0
- backend/services/job_manager.py +41 -2
- backend/services/rate_limit_service.py +641 -0
- backend/tests/conftest.py +7 -1
- 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_made_for_you.py +6 -4
- backend/tests/test_rate_limit_service.py +396 -0
- backend/tests/test_rate_limits_api.py +392 -0
- backend/workers/video_worker_orchestrator.py +26 -0
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +26 -18
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.103.1.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.103.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Admin API routes for rate limit management.
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- Rate limit statistics and monitoring
|
|
6
|
+
- Blocklist management (disposable domains, blocked emails, blocked IPs)
|
|
7
|
+
- User override management (whitelist/bypass permissions)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
15
|
+
from pydantic import BaseModel, EmailStr
|
|
16
|
+
|
|
17
|
+
from backend.api.dependencies import require_admin
|
|
18
|
+
from backend.services.auth_service import AuthResult
|
|
19
|
+
from backend.services.rate_limit_service import get_rate_limit_service, RateLimitService
|
|
20
|
+
from backend.services.email_validation_service import get_email_validation_service, EmailValidationService
|
|
21
|
+
from backend.config import settings
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
router = APIRouter(prefix="/admin/rate-limits", tags=["admin", "rate-limits"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# =============================================================================
|
|
28
|
+
# Response Models
|
|
29
|
+
# =============================================================================
|
|
30
|
+
|
|
31
|
+
class RateLimitStatsResponse(BaseModel):
|
|
32
|
+
"""Current rate limit statistics."""
|
|
33
|
+
# Configuration
|
|
34
|
+
jobs_per_day_limit: int
|
|
35
|
+
youtube_uploads_per_day_limit: int
|
|
36
|
+
beta_ip_per_day_limit: int
|
|
37
|
+
rate_limiting_enabled: bool
|
|
38
|
+
|
|
39
|
+
# Current usage
|
|
40
|
+
youtube_uploads_today: int
|
|
41
|
+
youtube_uploads_remaining: int
|
|
42
|
+
|
|
43
|
+
# Blocklist stats
|
|
44
|
+
disposable_domains_count: int
|
|
45
|
+
blocked_emails_count: int
|
|
46
|
+
blocked_ips_count: int
|
|
47
|
+
|
|
48
|
+
# User override stats
|
|
49
|
+
total_overrides: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UserRateLimitStatusResponse(BaseModel):
|
|
53
|
+
"""Rate limit status for a specific user."""
|
|
54
|
+
email: str
|
|
55
|
+
jobs_today: int
|
|
56
|
+
jobs_limit: int
|
|
57
|
+
jobs_remaining: int
|
|
58
|
+
has_bypass: bool
|
|
59
|
+
custom_limit: Optional[int]
|
|
60
|
+
bypass_reason: Optional[str]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BlocklistsResponse(BaseModel):
|
|
64
|
+
"""All blocklist data."""
|
|
65
|
+
disposable_domains: List[str]
|
|
66
|
+
blocked_emails: List[str]
|
|
67
|
+
blocked_ips: List[str]
|
|
68
|
+
updated_at: Optional[datetime]
|
|
69
|
+
updated_by: Optional[str]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DomainRequest(BaseModel):
|
|
73
|
+
"""Request to add/remove a domain."""
|
|
74
|
+
domain: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class EmailRequest(BaseModel):
|
|
78
|
+
"""Request to add/remove an email."""
|
|
79
|
+
email: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class IPRequest(BaseModel):
|
|
83
|
+
"""Request to add/remove an IP."""
|
|
84
|
+
ip_address: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class UserOverride(BaseModel):
|
|
88
|
+
"""User override configuration."""
|
|
89
|
+
email: str
|
|
90
|
+
bypass_job_limit: bool
|
|
91
|
+
custom_daily_job_limit: Optional[int]
|
|
92
|
+
reason: str
|
|
93
|
+
created_by: str
|
|
94
|
+
created_at: datetime
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class UserOverrideRequest(BaseModel):
|
|
98
|
+
"""Request to set/update a user override."""
|
|
99
|
+
bypass_job_limit: bool = False
|
|
100
|
+
custom_daily_job_limit: Optional[int] = None
|
|
101
|
+
reason: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class UserOverridesListResponse(BaseModel):
|
|
105
|
+
"""List of all user overrides."""
|
|
106
|
+
overrides: List[UserOverride]
|
|
107
|
+
total: int
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class SuccessResponse(BaseModel):
|
|
111
|
+
"""Generic success response."""
|
|
112
|
+
success: bool
|
|
113
|
+
message: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# =============================================================================
|
|
117
|
+
# Statistics Endpoints
|
|
118
|
+
# =============================================================================
|
|
119
|
+
|
|
120
|
+
@router.get("/stats", response_model=RateLimitStatsResponse)
|
|
121
|
+
async def get_rate_limit_stats(
|
|
122
|
+
auth_result: AuthResult = Depends(require_admin),
|
|
123
|
+
):
|
|
124
|
+
"""
|
|
125
|
+
Get current rate limit statistics.
|
|
126
|
+
|
|
127
|
+
Returns configuration values, current usage counts, and blocklist stats.
|
|
128
|
+
"""
|
|
129
|
+
rate_limit_service = get_rate_limit_service()
|
|
130
|
+
email_validation = get_email_validation_service()
|
|
131
|
+
|
|
132
|
+
# Get YouTube uploads today
|
|
133
|
+
youtube_today = rate_limit_service.get_youtube_uploads_today()
|
|
134
|
+
youtube_limit = settings.rate_limit_youtube_uploads_per_day
|
|
135
|
+
youtube_remaining = max(0, youtube_limit - youtube_today) if youtube_limit > 0 else -1
|
|
136
|
+
|
|
137
|
+
# Get blocklist stats
|
|
138
|
+
blocklist_stats = email_validation.get_blocklist_stats()
|
|
139
|
+
|
|
140
|
+
# Get override count
|
|
141
|
+
overrides = rate_limit_service.get_all_overrides()
|
|
142
|
+
|
|
143
|
+
return RateLimitStatsResponse(
|
|
144
|
+
jobs_per_day_limit=settings.rate_limit_jobs_per_day,
|
|
145
|
+
youtube_uploads_per_day_limit=settings.rate_limit_youtube_uploads_per_day,
|
|
146
|
+
beta_ip_per_day_limit=settings.rate_limit_beta_ip_per_day,
|
|
147
|
+
rate_limiting_enabled=settings.enable_rate_limiting,
|
|
148
|
+
youtube_uploads_today=youtube_today,
|
|
149
|
+
youtube_uploads_remaining=youtube_remaining,
|
|
150
|
+
disposable_domains_count=blocklist_stats["disposable_domains_count"],
|
|
151
|
+
blocked_emails_count=blocklist_stats["blocked_emails_count"],
|
|
152
|
+
blocked_ips_count=blocklist_stats["blocked_ips_count"],
|
|
153
|
+
total_overrides=len(overrides),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@router.get("/users/{email}", response_model=UserRateLimitStatusResponse)
|
|
158
|
+
async def get_user_rate_limit_status(
|
|
159
|
+
email: str,
|
|
160
|
+
auth_result: AuthResult = Depends(require_admin),
|
|
161
|
+
):
|
|
162
|
+
"""
|
|
163
|
+
Get rate limit status for a specific user.
|
|
164
|
+
|
|
165
|
+
Returns their current usage and any override settings.
|
|
166
|
+
"""
|
|
167
|
+
rate_limit_service = get_rate_limit_service()
|
|
168
|
+
|
|
169
|
+
jobs_today = rate_limit_service.get_user_job_count_today(email)
|
|
170
|
+
override = rate_limit_service.get_user_override(email)
|
|
171
|
+
|
|
172
|
+
if override:
|
|
173
|
+
has_bypass = override.get("bypass_job_limit", False)
|
|
174
|
+
custom_limit = override.get("custom_daily_job_limit")
|
|
175
|
+
bypass_reason = override.get("reason")
|
|
176
|
+
jobs_limit = custom_limit if custom_limit is not None else settings.rate_limit_jobs_per_day
|
|
177
|
+
else:
|
|
178
|
+
has_bypass = False
|
|
179
|
+
custom_limit = None
|
|
180
|
+
bypass_reason = None
|
|
181
|
+
jobs_limit = settings.rate_limit_jobs_per_day
|
|
182
|
+
|
|
183
|
+
jobs_remaining = max(0, jobs_limit - jobs_today) if not has_bypass else -1
|
|
184
|
+
|
|
185
|
+
return UserRateLimitStatusResponse(
|
|
186
|
+
email=email,
|
|
187
|
+
jobs_today=jobs_today,
|
|
188
|
+
jobs_limit=jobs_limit,
|
|
189
|
+
jobs_remaining=jobs_remaining,
|
|
190
|
+
has_bypass=has_bypass,
|
|
191
|
+
custom_limit=custom_limit,
|
|
192
|
+
bypass_reason=bypass_reason,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# =============================================================================
|
|
197
|
+
# Blocklist Management Endpoints
|
|
198
|
+
# =============================================================================
|
|
199
|
+
|
|
200
|
+
@router.get("/blocklists", response_model=BlocklistsResponse)
|
|
201
|
+
async def get_blocklists(
|
|
202
|
+
auth_result: AuthResult = Depends(require_admin),
|
|
203
|
+
):
|
|
204
|
+
"""
|
|
205
|
+
Get all blocklist data.
|
|
206
|
+
|
|
207
|
+
Returns disposable domains, blocked emails, and blocked IPs.
|
|
208
|
+
"""
|
|
209
|
+
email_validation = get_email_validation_service()
|
|
210
|
+
|
|
211
|
+
# Force refresh to get latest data
|
|
212
|
+
config = email_validation.get_blocklist_config(force_refresh=True)
|
|
213
|
+
|
|
214
|
+
# Get the raw document for metadata
|
|
215
|
+
from google.cloud import firestore
|
|
216
|
+
from backend.services.firestore_service import get_firestore_client
|
|
217
|
+
from backend.services.email_validation_service import BLOCKLISTS_COLLECTION, BLOCKLIST_CONFIG_DOC
|
|
218
|
+
|
|
219
|
+
db = get_firestore_client()
|
|
220
|
+
doc = db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC).get()
|
|
221
|
+
|
|
222
|
+
updated_at = None
|
|
223
|
+
updated_by = None
|
|
224
|
+
if doc.exists:
|
|
225
|
+
data = doc.to_dict()
|
|
226
|
+
updated_at = data.get("updated_at")
|
|
227
|
+
updated_by = data.get("updated_by")
|
|
228
|
+
|
|
229
|
+
return BlocklistsResponse(
|
|
230
|
+
disposable_domains=sorted(list(config["disposable_domains"])),
|
|
231
|
+
blocked_emails=sorted(list(config["blocked_emails"])),
|
|
232
|
+
blocked_ips=sorted(list(config["blocked_ips"])),
|
|
233
|
+
updated_at=updated_at,
|
|
234
|
+
updated_by=updated_by,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@router.post("/blocklists/disposable-domains", response_model=SuccessResponse)
|
|
239
|
+
async def add_disposable_domain(
|
|
240
|
+
request: DomainRequest,
|
|
241
|
+
auth_result: AuthResult = Depends(require_admin),
|
|
242
|
+
):
|
|
243
|
+
"""Add a domain to the disposable domains blocklist."""
|
|
244
|
+
email_validation = get_email_validation_service()
|
|
245
|
+
|
|
246
|
+
domain = request.domain.lower().strip()
|
|
247
|
+
if not domain or "." not in domain:
|
|
248
|
+
raise HTTPException(status_code=400, detail="Invalid domain format")
|
|
249
|
+
|
|
250
|
+
email_validation.add_disposable_domain(domain, auth_result.user_email)
|
|
251
|
+
|
|
252
|
+
return SuccessResponse(
|
|
253
|
+
success=True,
|
|
254
|
+
message=f"Domain '{domain}' added to disposable domains blocklist"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@router.delete("/blocklists/disposable-domains/{domain}", response_model=SuccessResponse)
|
|
259
|
+
async def remove_disposable_domain(
|
|
260
|
+
domain: str,
|
|
261
|
+
auth_result: AuthResult = Depends(require_admin),
|
|
262
|
+
):
|
|
263
|
+
"""Remove a domain from the disposable domains blocklist."""
|
|
264
|
+
email_validation = get_email_validation_service()
|
|
265
|
+
|
|
266
|
+
domain = domain.lower().strip()
|
|
267
|
+
if not email_validation.remove_disposable_domain(domain, auth_result.user_email):
|
|
268
|
+
raise HTTPException(status_code=404, detail="Domain not found in blocklist")
|
|
269
|
+
|
|
270
|
+
return SuccessResponse(
|
|
271
|
+
success=True,
|
|
272
|
+
message=f"Domain '{domain}' removed from disposable domains blocklist"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@router.post("/blocklists/blocked-emails", response_model=SuccessResponse)
|
|
277
|
+
async def add_blocked_email(
|
|
278
|
+
request: EmailRequest,
|
|
279
|
+
auth_result: AuthResult = Depends(require_admin),
|
|
280
|
+
):
|
|
281
|
+
"""Add an email to the blocked emails list."""
|
|
282
|
+
email_validation = get_email_validation_service()
|
|
283
|
+
|
|
284
|
+
email = request.email.lower().strip()
|
|
285
|
+
if not email or "@" not in email:
|
|
286
|
+
raise HTTPException(status_code=400, detail="Invalid email format")
|
|
287
|
+
|
|
288
|
+
email_validation.add_blocked_email(email, auth_result.user_email)
|
|
289
|
+
|
|
290
|
+
return SuccessResponse(
|
|
291
|
+
success=True,
|
|
292
|
+
message=f"Email '{email}' added to blocked emails list"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@router.delete("/blocklists/blocked-emails/{email}", response_model=SuccessResponse)
|
|
297
|
+
async def remove_blocked_email(
|
|
298
|
+
email: str,
|
|
299
|
+
auth_result: AuthResult = Depends(require_admin),
|
|
300
|
+
):
|
|
301
|
+
"""Remove an email from the blocked emails list."""
|
|
302
|
+
email_validation = get_email_validation_service()
|
|
303
|
+
|
|
304
|
+
email = email.lower().strip()
|
|
305
|
+
if not email_validation.remove_blocked_email(email, auth_result.user_email):
|
|
306
|
+
raise HTTPException(status_code=404, detail="Email not found in blocklist")
|
|
307
|
+
|
|
308
|
+
return SuccessResponse(
|
|
309
|
+
success=True,
|
|
310
|
+
message=f"Email '{email}' removed from blocked emails list"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@router.post("/blocklists/blocked-ips", response_model=SuccessResponse)
|
|
315
|
+
async def add_blocked_ip(
|
|
316
|
+
request: IPRequest,
|
|
317
|
+
auth_result: AuthResult = Depends(require_admin),
|
|
318
|
+
):
|
|
319
|
+
"""Add an IP address to the blocked IPs list."""
|
|
320
|
+
email_validation = get_email_validation_service()
|
|
321
|
+
|
|
322
|
+
ip_address = request.ip_address.strip()
|
|
323
|
+
if not ip_address:
|
|
324
|
+
raise HTTPException(status_code=400, detail="Invalid IP address")
|
|
325
|
+
|
|
326
|
+
email_validation.add_blocked_ip(ip_address, auth_result.user_email)
|
|
327
|
+
|
|
328
|
+
return SuccessResponse(
|
|
329
|
+
success=True,
|
|
330
|
+
message=f"IP '{ip_address}' added to blocked IPs list"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@router.delete("/blocklists/blocked-ips/{ip_address}", response_model=SuccessResponse)
|
|
335
|
+
async def remove_blocked_ip(
|
|
336
|
+
ip_address: str,
|
|
337
|
+
auth_result: AuthResult = Depends(require_admin),
|
|
338
|
+
):
|
|
339
|
+
"""Remove an IP address from the blocked IPs list."""
|
|
340
|
+
email_validation = get_email_validation_service()
|
|
341
|
+
|
|
342
|
+
ip_address = ip_address.strip()
|
|
343
|
+
if not email_validation.remove_blocked_ip(ip_address, auth_result.user_email):
|
|
344
|
+
raise HTTPException(status_code=404, detail="IP not found in blocklist")
|
|
345
|
+
|
|
346
|
+
return SuccessResponse(
|
|
347
|
+
success=True,
|
|
348
|
+
message=f"IP '{ip_address}' removed from blocked IPs list"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# =============================================================================
|
|
353
|
+
# User Override Management Endpoints
|
|
354
|
+
# =============================================================================
|
|
355
|
+
|
|
356
|
+
@router.get("/overrides", response_model=UserOverridesListResponse)
|
|
357
|
+
async def get_all_overrides(
|
|
358
|
+
auth_result: AuthResult = Depends(require_admin),
|
|
359
|
+
):
|
|
360
|
+
"""Get all user overrides."""
|
|
361
|
+
rate_limit_service = get_rate_limit_service()
|
|
362
|
+
|
|
363
|
+
overrides_data = rate_limit_service.get_all_overrides()
|
|
364
|
+
|
|
365
|
+
overrides = [
|
|
366
|
+
UserOverride(
|
|
367
|
+
email=email,
|
|
368
|
+
bypass_job_limit=data.get("bypass_job_limit", False),
|
|
369
|
+
custom_daily_job_limit=data.get("custom_daily_job_limit"),
|
|
370
|
+
reason=data.get("reason", ""),
|
|
371
|
+
created_by=data.get("created_by", "unknown"),
|
|
372
|
+
created_at=data.get("created_at", datetime.now(timezone.utc)),
|
|
373
|
+
)
|
|
374
|
+
for email, data in overrides_data.items()
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
return UserOverridesListResponse(
|
|
378
|
+
overrides=overrides,
|
|
379
|
+
total=len(overrides),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@router.put("/overrides/{email}", response_model=SuccessResponse)
|
|
384
|
+
async def set_user_override(
|
|
385
|
+
email: str,
|
|
386
|
+
request: UserOverrideRequest,
|
|
387
|
+
auth_result: AuthResult = Depends(require_admin),
|
|
388
|
+
):
|
|
389
|
+
"""Set or update a user override."""
|
|
390
|
+
rate_limit_service = get_rate_limit_service()
|
|
391
|
+
|
|
392
|
+
email = email.lower().strip()
|
|
393
|
+
if not email or "@" not in email:
|
|
394
|
+
raise HTTPException(status_code=400, detail="Invalid email format")
|
|
395
|
+
|
|
396
|
+
if not request.reason or len(request.reason.strip()) < 3:
|
|
397
|
+
raise HTTPException(status_code=400, detail="Reason is required (min 3 characters)")
|
|
398
|
+
|
|
399
|
+
rate_limit_service.set_user_override(
|
|
400
|
+
user_email=email,
|
|
401
|
+
bypass_job_limit=request.bypass_job_limit,
|
|
402
|
+
custom_daily_job_limit=request.custom_daily_job_limit,
|
|
403
|
+
reason=request.reason,
|
|
404
|
+
admin_email=auth_result.user_email,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
return SuccessResponse(
|
|
408
|
+
success=True,
|
|
409
|
+
message=f"Override set for user '{email}'"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@router.delete("/overrides/{email}", response_model=SuccessResponse)
|
|
414
|
+
async def remove_user_override(
|
|
415
|
+
email: str,
|
|
416
|
+
auth_result: AuthResult = Depends(require_admin),
|
|
417
|
+
):
|
|
418
|
+
"""Remove a user override."""
|
|
419
|
+
rate_limit_service = get_rate_limit_service()
|
|
420
|
+
|
|
421
|
+
email = email.lower().strip()
|
|
422
|
+
if not rate_limit_service.remove_user_override(email, auth_result.user_email):
|
|
423
|
+
raise HTTPException(status_code=404, detail="Override not found for user")
|
|
424
|
+
|
|
425
|
+
return SuccessResponse(
|
|
426
|
+
success=True,
|
|
427
|
+
message=f"Override removed for user '{email}'"
|
|
428
|
+
)
|
backend/api/routes/users.py
CHANGED
|
@@ -53,6 +53,10 @@ from backend.services.user_service import get_user_service, UserService, USERS_C
|
|
|
53
53
|
from backend.services.email_service import get_email_service, EmailService
|
|
54
54
|
from backend.services.stripe_service import get_stripe_service, StripeService, CREDIT_PACKAGES
|
|
55
55
|
from backend.services.theme_service import get_theme_service
|
|
56
|
+
from backend.services.job_defaults_service import (
|
|
57
|
+
get_effective_distribution_settings,
|
|
58
|
+
resolve_cdg_txt_defaults,
|
|
59
|
+
)
|
|
56
60
|
from backend.api.dependencies import require_admin
|
|
57
61
|
from backend.api.routes.file_upload import _prepare_theme_for_job
|
|
58
62
|
from backend.services.auth_service import UserType
|
|
@@ -475,7 +479,6 @@ async def _handle_made_for_you_order(
|
|
|
475
479
|
AudioSearchError,
|
|
476
480
|
)
|
|
477
481
|
from backend.services.storage_service import StorageService
|
|
478
|
-
from backend.config import get_settings
|
|
479
482
|
import asyncio
|
|
480
483
|
import tempfile
|
|
481
484
|
import os
|
|
@@ -496,7 +499,6 @@ async def _handle_made_for_you_order(
|
|
|
496
499
|
job_manager = JobManager()
|
|
497
500
|
worker_service = get_worker_service()
|
|
498
501
|
storage_service = StorageService()
|
|
499
|
-
settings = get_settings()
|
|
500
502
|
|
|
501
503
|
# Apply default theme (Nomad) - same as audio_search endpoint
|
|
502
504
|
theme_service = get_theme_service()
|
|
@@ -504,13 +506,12 @@ async def _handle_made_for_you_order(
|
|
|
504
506
|
if effective_theme_id:
|
|
505
507
|
logger.info(f"Applying default theme '{effective_theme_id}' for made-for-you order")
|
|
506
508
|
|
|
507
|
-
# Get distribution defaults
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
effective_youtube_description = settings.default_youtube_description
|
|
509
|
+
# Get distribution defaults using centralized service
|
|
510
|
+
dist = get_effective_distribution_settings()
|
|
511
|
+
|
|
512
|
+
# Resolve CDG/TXT defaults based on theme (uses centralized service)
|
|
513
|
+
# This ensures made-for-you jobs get the same CDG/TXT defaults as regular jobs
|
|
514
|
+
resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(effective_theme_id)
|
|
514
515
|
|
|
515
516
|
# Create job with admin ownership during processing
|
|
516
517
|
# CRITICAL: made_for_you=True, user_email=ADMIN_EMAIL, customer_email for delivery
|
|
@@ -530,15 +531,19 @@ async def _handle_made_for_you_order(
|
|
|
530
531
|
audio_search_artist=artist if not youtube_url else None,
|
|
531
532
|
audio_search_title=title if not youtube_url else None,
|
|
532
533
|
auto_download=False, # Pause at audio selection for admin to choose
|
|
533
|
-
#
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
534
|
+
# CDG/TXT settings (resolved via centralized service)
|
|
535
|
+
enable_cdg=resolved_cdg,
|
|
536
|
+
enable_txt=resolved_txt,
|
|
537
|
+
# Distribution settings from centralized defaults
|
|
538
|
+
enable_youtube_upload=dist.enable_youtube_upload,
|
|
539
|
+
dropbox_path=dist.dropbox_path,
|
|
540
|
+
gdrive_folder_id=dist.gdrive_folder_id,
|
|
541
|
+
brand_prefix=dist.brand_prefix,
|
|
542
|
+
discord_webhook_url=dist.discord_webhook_url,
|
|
543
|
+
youtube_description=dist.youtube_description,
|
|
540
544
|
)
|
|
541
|
-
|
|
545
|
+
# Made-for-you jobs are created by admin (via Stripe webhook) - bypass rate limits
|
|
546
|
+
job = job_manager.create_job(job_create, is_admin=True)
|
|
542
547
|
job_id = job.job_id
|
|
543
548
|
|
|
544
549
|
logger.info(f"Created made-for-you job {job_id} for {_mask_email(customer_email)} (owned by {_mask_email(ADMIN_EMAIL)})")
|
|
@@ -818,6 +823,9 @@ async def enroll_beta_tester(
|
|
|
818
823
|
|
|
819
824
|
Returns free credits and optionally a session token for new users.
|
|
820
825
|
"""
|
|
826
|
+
from backend.services.email_validation_service import get_email_validation_service
|
|
827
|
+
from backend.services.rate_limit_service import get_rate_limit_service
|
|
828
|
+
|
|
821
829
|
# Check if email service is configured
|
|
822
830
|
if not email_service.is_configured():
|
|
823
831
|
logger.error("Email service not configured - cannot send beta welcome emails")
|
|
@@ -827,6 +835,52 @@ async def enroll_beta_tester(
|
|
|
827
835
|
)
|
|
828
836
|
|
|
829
837
|
email = request.email.lower()
|
|
838
|
+
email_validation = get_email_validation_service()
|
|
839
|
+
rate_limit_service = get_rate_limit_service()
|
|
840
|
+
|
|
841
|
+
# ----- ABUSE PREVENTION CHECKS -----
|
|
842
|
+
|
|
843
|
+
# 1. Validate email (disposable domain, blocked email checks)
|
|
844
|
+
is_valid, error_message = email_validation.validate_email_for_beta(email)
|
|
845
|
+
if not is_valid:
|
|
846
|
+
logger.warning(f"Beta enrollment rejected - email validation failed: {email} - {error_message}")
|
|
847
|
+
raise HTTPException(
|
|
848
|
+
status_code=400,
|
|
849
|
+
detail=error_message
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
# 2. Check IP blocking
|
|
853
|
+
ip_address = http_request.client.host if http_request.client else None
|
|
854
|
+
if ip_address and email_validation.is_ip_blocked(ip_address):
|
|
855
|
+
logger.warning(f"Beta enrollment rejected - IP blocked: {ip_address}")
|
|
856
|
+
raise HTTPException(
|
|
857
|
+
status_code=403,
|
|
858
|
+
detail="Access denied from this location"
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
# 3. Check IP-based enrollment rate limit (1 per 24h per IP)
|
|
862
|
+
if ip_address:
|
|
863
|
+
allowed, remaining, message = rate_limit_service.check_beta_ip_limit(ip_address)
|
|
864
|
+
if not allowed:
|
|
865
|
+
logger.warning(f"Beta enrollment rejected - IP rate limit: {ip_address} - {message}")
|
|
866
|
+
raise HTTPException(
|
|
867
|
+
status_code=429,
|
|
868
|
+
detail="Too many beta enrollments from your location. Please try again tomorrow."
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
# 4. Check for duplicate enrollment via normalized email
|
|
872
|
+
normalized_email = email_validation.normalize_email(email)
|
|
873
|
+
if normalized_email != email:
|
|
874
|
+
# Check if normalized version is already enrolled
|
|
875
|
+
normalized_user = user_service.get_user(normalized_email)
|
|
876
|
+
if normalized_user and normalized_user.is_beta_tester:
|
|
877
|
+
logger.warning(f"Beta enrollment rejected - normalized email already enrolled: {email} -> {normalized_email}")
|
|
878
|
+
raise HTTPException(
|
|
879
|
+
status_code=400,
|
|
880
|
+
detail="An account with this email address is already enrolled in the beta program"
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
# ----- END ABUSE PREVENTION CHECKS -----
|
|
830
884
|
|
|
831
885
|
# Validate acceptance
|
|
832
886
|
if not request.accept_corrections_work:
|
|
@@ -851,8 +905,7 @@ async def enroll_beta_tester(
|
|
|
851
905
|
detail="You are already enrolled in the beta program"
|
|
852
906
|
)
|
|
853
907
|
|
|
854
|
-
# Get client info
|
|
855
|
-
ip_address = http_request.client.host if http_request.client else None
|
|
908
|
+
# Get client info (ip_address already set above in abuse prevention)
|
|
856
909
|
user_agent = http_request.headers.get("user-agent")
|
|
857
910
|
|
|
858
911
|
# Enroll as beta tester
|
|
@@ -872,6 +925,13 @@ async def enroll_beta_tester(
|
|
|
872
925
|
reason="beta_tester_enrollment",
|
|
873
926
|
)
|
|
874
927
|
|
|
928
|
+
# Record IP enrollment for rate limiting
|
|
929
|
+
if ip_address:
|
|
930
|
+
try:
|
|
931
|
+
rate_limit_service.record_beta_enrollment(ip_address, email)
|
|
932
|
+
except Exception as e:
|
|
933
|
+
logger.warning(f"Failed to record beta IP enrollment: {e}")
|
|
934
|
+
|
|
875
935
|
# Create session for the user (so they can start using the service immediately)
|
|
876
936
|
session = user_service.create_session(email, ip_address=ip_address, user_agent=user_agent)
|
|
877
937
|
|
backend/config.py
CHANGED
|
@@ -101,12 +101,28 @@ class Settings(BaseSettings):
|
|
|
101
101
|
# Default values for web service jobs (YouTube/Dropbox distribution)
|
|
102
102
|
default_enable_youtube_upload: bool = os.getenv("DEFAULT_ENABLE_YOUTUBE_UPLOAD", "false").lower() in ("true", "1", "yes")
|
|
103
103
|
default_brand_prefix: Optional[str] = os.getenv("DEFAULT_BRAND_PREFIX")
|
|
104
|
+
|
|
105
|
+
# Rate Limiting Configuration
|
|
106
|
+
# Enable/disable rate limiting system-wide (useful for development)
|
|
107
|
+
enable_rate_limiting: bool = os.getenv("ENABLE_RATE_LIMITING", "true").lower() in ("true", "1", "yes")
|
|
108
|
+
# Maximum jobs a user can create per day (0 = unlimited)
|
|
109
|
+
rate_limit_jobs_per_day: int = int(os.getenv("RATE_LIMIT_JOBS_PER_DAY", "5"))
|
|
110
|
+
# Maximum YouTube uploads system-wide per day (0 = unlimited)
|
|
111
|
+
rate_limit_youtube_uploads_per_day: int = int(os.getenv("RATE_LIMIT_YOUTUBE_UPLOADS_PER_DAY", "10"))
|
|
112
|
+
# Maximum beta enrollments from same IP per day (0 = unlimited)
|
|
113
|
+
rate_limit_beta_ip_per_day: int = int(os.getenv("RATE_LIMIT_BETA_IP_PER_DAY", "1"))
|
|
104
114
|
default_youtube_description: str = os.getenv(
|
|
105
115
|
"DEFAULT_YOUTUBE_DESCRIPTION",
|
|
106
116
|
"Karaoke video created with Nomad Karaoke (https://nomadkaraoke.com)\n\n"
|
|
107
117
|
"AI-powered vocal separation and synchronized lyrics.\n\n"
|
|
108
118
|
"#karaoke #music #singing #instrumental #lyrics"
|
|
109
119
|
)
|
|
120
|
+
|
|
121
|
+
# Default CDG/TXT generation settings
|
|
122
|
+
# When True, CDG and TXT packages are generated by default (when a theme is set)
|
|
123
|
+
# These can be overridden per-request via explicit enable_cdg/enable_txt parameters
|
|
124
|
+
default_enable_cdg: bool = os.getenv("DEFAULT_ENABLE_CDG", "true").lower() in ("true", "1", "yes")
|
|
125
|
+
default_enable_txt: bool = os.getenv("DEFAULT_ENABLE_TXT", "true").lower() in ("true", "1", "yes")
|
|
110
126
|
|
|
111
127
|
# Secret Manager cache
|
|
112
128
|
_secret_cache: Dict[str, str] = {}
|
backend/exceptions.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for the karaoke generation backend.
|
|
3
|
+
|
|
4
|
+
These exceptions are used for structured error handling across the application,
|
|
5
|
+
particularly for rate limiting and validation errors.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RateLimitExceededError(Exception):
|
|
10
|
+
"""
|
|
11
|
+
Raised when a rate limit is exceeded.
|
|
12
|
+
|
|
13
|
+
Includes information about when the limit will reset for client retry logic.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
message: Human-readable error message
|
|
17
|
+
limit_type: Type of limit exceeded (e.g., "jobs_per_day", "youtube_uploads")
|
|
18
|
+
remaining_seconds: Seconds until the limit resets (for Retry-After header)
|
|
19
|
+
current_count: Current usage count
|
|
20
|
+
limit_value: The limit that was exceeded
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
message: str,
|
|
26
|
+
limit_type: str = "unknown",
|
|
27
|
+
remaining_seconds: int = 0,
|
|
28
|
+
current_count: int = 0,
|
|
29
|
+
limit_value: int = 0
|
|
30
|
+
):
|
|
31
|
+
self.message = message
|
|
32
|
+
self.limit_type = limit_type
|
|
33
|
+
self.remaining_seconds = remaining_seconds
|
|
34
|
+
self.current_count = current_count
|
|
35
|
+
self.limit_value = limit_value
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EmailValidationError(Exception):
|
|
40
|
+
"""
|
|
41
|
+
Raised when email validation fails.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
message: Human-readable error message
|
|
45
|
+
reason: Specific reason for failure (e.g., "disposable", "blocked", "invalid")
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, message: str, reason: str = "invalid"):
|
|
49
|
+
self.message = message
|
|
50
|
+
self.reason = reason
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class IPBlockedError(Exception):
|
|
55
|
+
"""
|
|
56
|
+
Raised when a request comes from a blocked IP address.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
message: Human-readable error message
|
|
60
|
+
ip_address: The blocked IP address (may be partially masked)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, message: str, ip_address: str = ""):
|
|
64
|
+
self.message = message
|
|
65
|
+
self.ip_address = ip_address
|
|
66
|
+
super().__init__(message)
|