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.
@@ -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
+ )
@@ -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 from settings (same as audio_search endpoint)
508
- effective_dropbox_path = settings.default_dropbox_path
509
- effective_gdrive_folder_id = settings.default_gdrive_folder_id
510
- effective_enable_youtube_upload = settings.default_enable_youtube_upload
511
- effective_brand_prefix = settings.default_brand_prefix
512
- effective_discord_webhook_url = settings.default_discord_webhook_url
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
- # Distribution settings from server defaults
534
- enable_youtube_upload=effective_enable_youtube_upload,
535
- dropbox_path=effective_dropbox_path,
536
- gdrive_folder_id=effective_gdrive_folder_id,
537
- brand_prefix=effective_brand_prefix,
538
- discord_webhook_url=effective_discord_webhook_url,
539
- youtube_description=effective_youtube_description,
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
- job = job_manager.create_job(job_create)
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)