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,238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Push Notification API routes.
|
|
3
|
+
|
|
4
|
+
Provides endpoints for managing Web Push notification subscriptions:
|
|
5
|
+
- GET /api/push/vapid-public-key: Get VAPID public key for client-side subscription
|
|
6
|
+
- POST /api/push/subscribe: Register a push subscription
|
|
7
|
+
- POST /api/push/unsubscribe: Remove a push subscription
|
|
8
|
+
- GET /api/push/subscriptions: List user's subscriptions
|
|
9
|
+
- POST /api/push/test: Send a test notification (admin only)
|
|
10
|
+
"""
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Optional, Dict, List
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
from backend.config import get_settings
|
|
18
|
+
from backend.api.dependencies import require_auth, require_admin
|
|
19
|
+
from backend.services.auth_service import AuthResult
|
|
20
|
+
from backend.services.push_notification_service import get_push_notification_service
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
router = APIRouter(prefix="/push", tags=["push"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Request/Response Models
|
|
28
|
+
|
|
29
|
+
class VapidPublicKeyResponse(BaseModel):
|
|
30
|
+
"""Response containing VAPID public key."""
|
|
31
|
+
enabled: bool
|
|
32
|
+
vapid_public_key: Optional[str] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SubscribeRequest(BaseModel):
|
|
36
|
+
"""Request to subscribe to push notifications."""
|
|
37
|
+
endpoint: str
|
|
38
|
+
keys: Dict[str, str] # p256dh and auth
|
|
39
|
+
device_name: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SubscribeResponse(BaseModel):
|
|
43
|
+
"""Response after subscribing."""
|
|
44
|
+
status: str
|
|
45
|
+
message: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UnsubscribeRequest(BaseModel):
|
|
49
|
+
"""Request to unsubscribe from push notifications."""
|
|
50
|
+
endpoint: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class UnsubscribeResponse(BaseModel):
|
|
54
|
+
"""Response after unsubscribing."""
|
|
55
|
+
status: str
|
|
56
|
+
message: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SubscriptionInfo(BaseModel):
|
|
60
|
+
"""Information about a push subscription."""
|
|
61
|
+
endpoint: str
|
|
62
|
+
device_name: Optional[str] = None
|
|
63
|
+
created_at: Optional[str] = None
|
|
64
|
+
last_used_at: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SubscriptionsListResponse(BaseModel):
|
|
68
|
+
"""Response containing user's subscriptions."""
|
|
69
|
+
subscriptions: List[SubscriptionInfo]
|
|
70
|
+
count: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestNotificationRequest(BaseModel):
|
|
74
|
+
"""Request to send a test notification."""
|
|
75
|
+
title: Optional[str] = "Test Notification"
|
|
76
|
+
body: Optional[str] = "This is a test push notification from Karaoke Generator"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestNotificationResponse(BaseModel):
|
|
80
|
+
"""Response after sending test notification."""
|
|
81
|
+
status: str
|
|
82
|
+
sent_count: int
|
|
83
|
+
message: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Routes
|
|
87
|
+
|
|
88
|
+
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
|
|
89
|
+
async def get_vapid_public_key():
|
|
90
|
+
"""
|
|
91
|
+
Get the VAPID public key for push subscription.
|
|
92
|
+
|
|
93
|
+
This endpoint is public - no authentication required.
|
|
94
|
+
Returns the public key needed for client-side PushManager.subscribe().
|
|
95
|
+
"""
|
|
96
|
+
settings = get_settings()
|
|
97
|
+
push_service = get_push_notification_service()
|
|
98
|
+
|
|
99
|
+
if not settings.enable_push_notifications:
|
|
100
|
+
return VapidPublicKeyResponse(enabled=False)
|
|
101
|
+
|
|
102
|
+
public_key = push_service.get_public_key()
|
|
103
|
+
if not public_key:
|
|
104
|
+
return VapidPublicKeyResponse(enabled=False)
|
|
105
|
+
|
|
106
|
+
return VapidPublicKeyResponse(
|
|
107
|
+
enabled=True,
|
|
108
|
+
vapid_public_key=public_key
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@router.post("/subscribe", response_model=SubscribeResponse)
|
|
113
|
+
async def subscribe_push(
|
|
114
|
+
request: SubscribeRequest,
|
|
115
|
+
auth_result: AuthResult = Depends(require_auth)
|
|
116
|
+
):
|
|
117
|
+
"""
|
|
118
|
+
Register a push notification subscription for the current user.
|
|
119
|
+
|
|
120
|
+
Requires authentication. Users can have up to 5 subscriptions
|
|
121
|
+
(configurable via MAX_PUSH_SUBSCRIPTIONS_PER_USER).
|
|
122
|
+
"""
|
|
123
|
+
settings = get_settings()
|
|
124
|
+
if not settings.enable_push_notifications:
|
|
125
|
+
raise HTTPException(status_code=503, detail="Push notifications are not enabled")
|
|
126
|
+
|
|
127
|
+
if not auth_result.user_email:
|
|
128
|
+
raise HTTPException(status_code=401, detail="User email not available")
|
|
129
|
+
|
|
130
|
+
push_service = get_push_notification_service()
|
|
131
|
+
|
|
132
|
+
# Validate keys
|
|
133
|
+
if "p256dh" not in request.keys or "auth" not in request.keys:
|
|
134
|
+
raise HTTPException(status_code=400, detail="Missing required keys (p256dh, auth)")
|
|
135
|
+
|
|
136
|
+
success = await push_service.add_subscription(
|
|
137
|
+
user_email=auth_result.user_email,
|
|
138
|
+
endpoint=request.endpoint,
|
|
139
|
+
keys=request.keys,
|
|
140
|
+
device_name=request.device_name
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if not success:
|
|
144
|
+
raise HTTPException(status_code=500, detail="Failed to save subscription")
|
|
145
|
+
|
|
146
|
+
return SubscribeResponse(
|
|
147
|
+
status="success",
|
|
148
|
+
message="Push subscription registered successfully"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@router.post("/unsubscribe", response_model=UnsubscribeResponse)
|
|
153
|
+
async def unsubscribe_push(
|
|
154
|
+
request: UnsubscribeRequest,
|
|
155
|
+
auth_result: AuthResult = Depends(require_auth)
|
|
156
|
+
):
|
|
157
|
+
"""
|
|
158
|
+
Remove a push notification subscription.
|
|
159
|
+
|
|
160
|
+
Requires authentication. Users can only remove their own subscriptions.
|
|
161
|
+
"""
|
|
162
|
+
if not auth_result.user_email:
|
|
163
|
+
raise HTTPException(status_code=401, detail="User email not available")
|
|
164
|
+
|
|
165
|
+
push_service = get_push_notification_service()
|
|
166
|
+
|
|
167
|
+
success = await push_service.remove_subscription(
|
|
168
|
+
user_email=auth_result.user_email,
|
|
169
|
+
endpoint=request.endpoint
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if not success:
|
|
173
|
+
# Don't error if subscription wasn't found - might already be removed
|
|
174
|
+
return UnsubscribeResponse(
|
|
175
|
+
status="success",
|
|
176
|
+
message="Subscription removed (or was not found)"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return UnsubscribeResponse(
|
|
180
|
+
status="success",
|
|
181
|
+
message="Push subscription removed successfully"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@router.get("/subscriptions", response_model=SubscriptionsListResponse)
|
|
186
|
+
async def list_subscriptions(
|
|
187
|
+
auth_result: AuthResult = Depends(require_auth)
|
|
188
|
+
):
|
|
189
|
+
"""
|
|
190
|
+
List all push notification subscriptions for the current user.
|
|
191
|
+
|
|
192
|
+
Requires authentication.
|
|
193
|
+
"""
|
|
194
|
+
if not auth_result.user_email:
|
|
195
|
+
raise HTTPException(status_code=401, detail="User email not available")
|
|
196
|
+
|
|
197
|
+
push_service = get_push_notification_service()
|
|
198
|
+
|
|
199
|
+
subscriptions = await push_service.list_subscriptions(auth_result.user_email)
|
|
200
|
+
|
|
201
|
+
return SubscriptionsListResponse(
|
|
202
|
+
subscriptions=[SubscriptionInfo(**s) for s in subscriptions],
|
|
203
|
+
count=len(subscriptions)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@router.post("/test", response_model=TestNotificationResponse)
|
|
208
|
+
async def send_test_notification(
|
|
209
|
+
request: TestNotificationRequest,
|
|
210
|
+
auth_result: AuthResult = Depends(require_admin)
|
|
211
|
+
):
|
|
212
|
+
"""
|
|
213
|
+
Send a test push notification to the current user's devices.
|
|
214
|
+
|
|
215
|
+
Admin only. Useful for testing push notification setup.
|
|
216
|
+
"""
|
|
217
|
+
settings = get_settings()
|
|
218
|
+
if not settings.enable_push_notifications:
|
|
219
|
+
raise HTTPException(status_code=503, detail="Push notifications are not enabled")
|
|
220
|
+
|
|
221
|
+
if not auth_result.user_email:
|
|
222
|
+
raise HTTPException(status_code=401, detail="User email not available")
|
|
223
|
+
|
|
224
|
+
push_service = get_push_notification_service()
|
|
225
|
+
|
|
226
|
+
sent_count = await push_service.send_push(
|
|
227
|
+
user_email=auth_result.user_email,
|
|
228
|
+
title=request.title or "Test Notification",
|
|
229
|
+
body=request.body or "This is a test push notification",
|
|
230
|
+
url="/app/",
|
|
231
|
+
tag="test"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return TestNotificationResponse(
|
|
235
|
+
status="success",
|
|
236
|
+
sent_count=sent_count,
|
|
237
|
+
message=f"Test notification sent to {sent_count} device(s)"
|
|
238
|
+
)
|
|
@@ -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
|
+
)
|