karaoke-gen 0.86.7__py3-none-any.whl → 0.96.0__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/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +742 -0
- backend/api/routes/audio_search.py +903 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2076 -0
- backend/api/routes/health.py +344 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1610 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1014 -0
- backend/config.py +172 -0
- backend/main.py +133 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +405 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +842 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +275 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +88 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +339 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +273 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +525 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/style_loader.py +3 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication service for karaoke backend.
|
|
3
|
+
|
|
4
|
+
Provides token-based authentication with support for:
|
|
5
|
+
- Admin tokens (hardcoded in environment)
|
|
6
|
+
- Admin by email domain (@nomadkaraoke.com)
|
|
7
|
+
- User tokens (stored in Firestore auth_tokens collection)
|
|
8
|
+
- Session tokens (stored in Firestore sessions collection, from magic link auth)
|
|
9
|
+
- API keys for business users
|
|
10
|
+
- Usage tracking and limits
|
|
11
|
+
- Token management API
|
|
12
|
+
"""
|
|
13
|
+
import logging
|
|
14
|
+
import hashlib
|
|
15
|
+
import time
|
|
16
|
+
import secrets
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Optional, Tuple, Dict, Any, List
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
|
|
22
|
+
from backend.services.firestore_service import FirestoreService
|
|
23
|
+
from backend.config import get_settings
|
|
24
|
+
from backend.models.user import UserRole
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Admin email domains - users with these domains are automatically admin
|
|
30
|
+
ADMIN_EMAIL_DOMAINS = ["nomadkaraoke.com"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UserType(str, Enum):
|
|
34
|
+
"""User access levels."""
|
|
35
|
+
ADMIN = "admin" # Unlimited access, can manage tokens
|
|
36
|
+
UNLIMITED = "unlimited" # Unlimited karaoke generation
|
|
37
|
+
LIMITED = "limited" # Limited number of uses
|
|
38
|
+
STRIPE = "stripe" # Paid access via Stripe
|
|
39
|
+
API_KEY = "api_key" # Business API key access
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class AuthResult:
|
|
44
|
+
"""Result of authentication validation."""
|
|
45
|
+
is_valid: bool
|
|
46
|
+
user_type: UserType
|
|
47
|
+
remaining_uses: int # -1 = unlimited, 0 = exhausted, >0 = remaining
|
|
48
|
+
message: str
|
|
49
|
+
user_email: Optional[str] = None # Email if authenticated via session/API key
|
|
50
|
+
is_admin: bool = False # True if admin token or admin email domain
|
|
51
|
+
api_key_id: Optional[str] = None # API key ID if authenticated via API key
|
|
52
|
+
|
|
53
|
+
def __iter__(self):
|
|
54
|
+
"""Allow unpacking as tuple for backward compatibility."""
|
|
55
|
+
return iter((self.is_valid, self.user_type, self.remaining_uses, self.message))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_admin_email(email: str) -> bool:
|
|
59
|
+
"""Check if an email belongs to an admin domain."""
|
|
60
|
+
if not email:
|
|
61
|
+
return False
|
|
62
|
+
email_lower = email.lower()
|
|
63
|
+
return any(email_lower.endswith(f"@{domain}") for domain in ADMIN_EMAIL_DOMAINS)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AuthService:
|
|
67
|
+
"""Service for authentication and token management."""
|
|
68
|
+
|
|
69
|
+
def __init__(self):
|
|
70
|
+
"""Initialize auth service."""
|
|
71
|
+
self.firestore = FirestoreService()
|
|
72
|
+
self.settings = get_settings()
|
|
73
|
+
self._init_admin_tokens()
|
|
74
|
+
|
|
75
|
+
def _init_admin_tokens(self):
|
|
76
|
+
"""Load admin tokens from environment variables."""
|
|
77
|
+
# Get admin tokens from secret manager or env vars
|
|
78
|
+
admin_tokens_str = self.settings.admin_tokens or ""
|
|
79
|
+
self.admin_tokens = [t.strip() for t in admin_tokens_str.split(",") if t.strip()]
|
|
80
|
+
|
|
81
|
+
if self.admin_tokens:
|
|
82
|
+
logger.info(f"Loaded {len(self.admin_tokens)} admin token(s)")
|
|
83
|
+
else:
|
|
84
|
+
logger.warning("No admin tokens configured! Set ADMIN_TOKENS environment variable.")
|
|
85
|
+
|
|
86
|
+
def validate_token(self, token: str) -> Tuple[bool, UserType, int, str]:
|
|
87
|
+
"""
|
|
88
|
+
Validate an access token.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
(is_valid, user_type, remaining_uses, message)
|
|
92
|
+
remaining_uses: -1 = unlimited, 0 = exhausted, >0 = remaining count
|
|
93
|
+
"""
|
|
94
|
+
if not token:
|
|
95
|
+
return False, UserType.LIMITED, 0, "No token provided"
|
|
96
|
+
|
|
97
|
+
# Check for admin tokens (highest priority)
|
|
98
|
+
if token in self.admin_tokens:
|
|
99
|
+
logger.info("Admin token validated")
|
|
100
|
+
return True, UserType.ADMIN, -1, "Admin access granted"
|
|
101
|
+
|
|
102
|
+
# Debug: Log token comparison info (only first/last few chars for security)
|
|
103
|
+
if self.admin_tokens:
|
|
104
|
+
expected_prefix = self.admin_tokens[0][:8] if len(self.admin_tokens[0]) >= 8 else self.admin_tokens[0]
|
|
105
|
+
provided_prefix = token[:8] if len(token) >= 8 else token
|
|
106
|
+
logger.debug(
|
|
107
|
+
f"Token mismatch: expected prefix '{expected_prefix}...', "
|
|
108
|
+
f"got '{provided_prefix}...', "
|
|
109
|
+
f"expected len={len(self.admin_tokens[0])}, got len={len(token)}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Check stored tokens in Firestore (auth_tokens collection)
|
|
113
|
+
token_data = self.firestore.get_token(token)
|
|
114
|
+
|
|
115
|
+
if not token_data:
|
|
116
|
+
# Fall back to checking session tokens (from magic link auth)
|
|
117
|
+
# Import here to avoid circular dependency
|
|
118
|
+
from backend.services.user_service import get_user_service
|
|
119
|
+
|
|
120
|
+
user_service = get_user_service()
|
|
121
|
+
is_valid, user, message = user_service.validate_session(token)
|
|
122
|
+
|
|
123
|
+
if is_valid and user:
|
|
124
|
+
# Session is valid - user is authenticated via magic link
|
|
125
|
+
# Return their credits as remaining uses
|
|
126
|
+
credits = user.credits
|
|
127
|
+
logger.info(f"Session token validated for user {user.email} (credits: {credits})")
|
|
128
|
+
|
|
129
|
+
if credits <= 0:
|
|
130
|
+
# User has no credits but is authenticated
|
|
131
|
+
return True, UserType.STRIPE, 0, f"Authenticated but no credits remaining"
|
|
132
|
+
|
|
133
|
+
return True, UserType.STRIPE, credits, f"Session valid: {credits} credits"
|
|
134
|
+
|
|
135
|
+
# Neither auth_token nor session found
|
|
136
|
+
return False, UserType.LIMITED, 0, "Invalid token"
|
|
137
|
+
|
|
138
|
+
# Check if token is active
|
|
139
|
+
if not token_data.get("active", True):
|
|
140
|
+
return False, UserType(token_data["type"]), 0, "Token has been revoked"
|
|
141
|
+
|
|
142
|
+
token_type = UserType(token_data["type"])
|
|
143
|
+
max_uses = token_data.get("max_uses", -1)
|
|
144
|
+
|
|
145
|
+
# UNLIMITED tokens: no usage limits
|
|
146
|
+
if token_type == UserType.UNLIMITED:
|
|
147
|
+
return True, token_type, -1, "Unlimited access granted"
|
|
148
|
+
|
|
149
|
+
# LIMITED tokens: check usage count
|
|
150
|
+
if token_type == UserType.LIMITED:
|
|
151
|
+
if max_uses <= 0: # -1 means unlimited
|
|
152
|
+
return True, token_type, -1, "Limited token with unlimited uses"
|
|
153
|
+
|
|
154
|
+
current_uses = token_data.get("usage_count", 0)
|
|
155
|
+
remaining = max_uses - current_uses
|
|
156
|
+
|
|
157
|
+
if remaining <= 0:
|
|
158
|
+
return False, token_type, 0, "Token usage limit exceeded"
|
|
159
|
+
|
|
160
|
+
return True, token_type, remaining, f"Limited token: {remaining} uses remaining"
|
|
161
|
+
|
|
162
|
+
# STRIPE tokens: check expiration and usage
|
|
163
|
+
if token_type == UserType.STRIPE:
|
|
164
|
+
expires_at = token_data.get("expires_at")
|
|
165
|
+
|
|
166
|
+
if expires_at:
|
|
167
|
+
# expires_at is stored as timestamp
|
|
168
|
+
if time.time() > expires_at:
|
|
169
|
+
return False, token_type, 0, "Token has expired"
|
|
170
|
+
|
|
171
|
+
if max_uses > 0:
|
|
172
|
+
current_uses = token_data.get("usage_count", 0)
|
|
173
|
+
remaining = max_uses - current_uses
|
|
174
|
+
|
|
175
|
+
if remaining <= 0:
|
|
176
|
+
return False, token_type, 0, "Token usage limit exceeded"
|
|
177
|
+
|
|
178
|
+
return True, token_type, remaining, f"Stripe token: {remaining} uses remaining"
|
|
179
|
+
|
|
180
|
+
return True, token_type, -1, "Stripe access granted"
|
|
181
|
+
|
|
182
|
+
return False, UserType.LIMITED, 0, "Unknown token type"
|
|
183
|
+
|
|
184
|
+
def validate_token_full(self, token: str) -> AuthResult:
|
|
185
|
+
"""
|
|
186
|
+
Validate an access token and return full authentication details.
|
|
187
|
+
|
|
188
|
+
This is the preferred method for new code as it returns complete
|
|
189
|
+
authentication context including user email and admin status.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
AuthResult with full authentication details
|
|
193
|
+
"""
|
|
194
|
+
if not token:
|
|
195
|
+
return AuthResult(
|
|
196
|
+
is_valid=False,
|
|
197
|
+
user_type=UserType.LIMITED,
|
|
198
|
+
remaining_uses=0,
|
|
199
|
+
message="No token provided"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Check for admin tokens (highest priority)
|
|
203
|
+
# Admin tokens from env var are associated with the system admin email
|
|
204
|
+
if token in self.admin_tokens:
|
|
205
|
+
logger.info("Admin token validated")
|
|
206
|
+
return AuthResult(
|
|
207
|
+
is_valid=True,
|
|
208
|
+
user_type=UserType.ADMIN,
|
|
209
|
+
remaining_uses=-1,
|
|
210
|
+
message="Admin access granted",
|
|
211
|
+
user_email="admin@nomadkaraoke.com",
|
|
212
|
+
is_admin=True
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Check stored tokens in Firestore (auth_tokens collection)
|
|
216
|
+
token_data = self.firestore.get_token(token)
|
|
217
|
+
|
|
218
|
+
if not token_data:
|
|
219
|
+
# Fall back to checking session tokens (from magic link auth)
|
|
220
|
+
from backend.services.user_service import get_user_service
|
|
221
|
+
|
|
222
|
+
user_service = get_user_service()
|
|
223
|
+
is_valid, user, _message = user_service.validate_session(token)
|
|
224
|
+
|
|
225
|
+
if is_valid and user:
|
|
226
|
+
# Session is valid - user is authenticated via magic link
|
|
227
|
+
credits = user.credits
|
|
228
|
+
user_email = user.email
|
|
229
|
+
|
|
230
|
+
# Check if user is admin by email domain or role
|
|
231
|
+
user_is_admin = is_admin_email(user_email) or user.role == UserRole.ADMIN
|
|
232
|
+
|
|
233
|
+
if user_is_admin:
|
|
234
|
+
logger.info(f"Admin session validated for {user_email}")
|
|
235
|
+
return AuthResult(
|
|
236
|
+
is_valid=True,
|
|
237
|
+
user_type=UserType.ADMIN,
|
|
238
|
+
remaining_uses=-1,
|
|
239
|
+
message="Admin access granted (domain)",
|
|
240
|
+
user_email=user_email,
|
|
241
|
+
is_admin=True
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
logger.info(f"Session token validated for user {user_email} (credits: {credits})")
|
|
245
|
+
|
|
246
|
+
if credits <= 0:
|
|
247
|
+
return AuthResult(
|
|
248
|
+
is_valid=True,
|
|
249
|
+
user_type=UserType.STRIPE,
|
|
250
|
+
remaining_uses=0,
|
|
251
|
+
message="Authenticated but no credits remaining",
|
|
252
|
+
user_email=user_email,
|
|
253
|
+
is_admin=False
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return AuthResult(
|
|
257
|
+
is_valid=True,
|
|
258
|
+
user_type=UserType.STRIPE,
|
|
259
|
+
remaining_uses=credits,
|
|
260
|
+
message=f"Session valid: {credits} credits",
|
|
261
|
+
user_email=user_email,
|
|
262
|
+
is_admin=False
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Neither auth_token nor session found
|
|
266
|
+
return AuthResult(
|
|
267
|
+
is_valid=False,
|
|
268
|
+
user_type=UserType.LIMITED,
|
|
269
|
+
remaining_uses=0,
|
|
270
|
+
message="Invalid token"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Check if token is active
|
|
274
|
+
if not token_data.get("active", True):
|
|
275
|
+
return AuthResult(
|
|
276
|
+
is_valid=False,
|
|
277
|
+
user_type=UserType(token_data["type"]),
|
|
278
|
+
remaining_uses=0,
|
|
279
|
+
message="Token has been revoked"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
token_type = UserType(token_data["type"])
|
|
283
|
+
max_uses = token_data.get("max_uses", -1)
|
|
284
|
+
# All auth_tokens must have an associated user_email for job ownership
|
|
285
|
+
token_user_email = token_data.get("user_email")
|
|
286
|
+
api_key_id = token_data.get("api_key_id")
|
|
287
|
+
|
|
288
|
+
# Require user_email on all auth_tokens (no anonymous token auth)
|
|
289
|
+
if not token_user_email:
|
|
290
|
+
logger.warning("Auth token missing required user_email field")
|
|
291
|
+
return AuthResult(
|
|
292
|
+
is_valid=False,
|
|
293
|
+
user_type=token_type,
|
|
294
|
+
remaining_uses=0,
|
|
295
|
+
message="Token configuration error: missing user_email. Please contact support."
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Check if token's user is an admin (by email domain)
|
|
299
|
+
token_is_admin = is_admin_email(token_user_email)
|
|
300
|
+
|
|
301
|
+
# UNLIMITED tokens: no usage limits
|
|
302
|
+
if token_type == UserType.UNLIMITED:
|
|
303
|
+
return AuthResult(
|
|
304
|
+
is_valid=True,
|
|
305
|
+
user_type=token_type,
|
|
306
|
+
remaining_uses=-1,
|
|
307
|
+
message="Unlimited access granted",
|
|
308
|
+
user_email=token_user_email,
|
|
309
|
+
is_admin=token_is_admin,
|
|
310
|
+
api_key_id=api_key_id
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# LIMITED tokens: check usage count
|
|
314
|
+
if token_type == UserType.LIMITED:
|
|
315
|
+
if max_uses <= 0: # -1 means unlimited
|
|
316
|
+
return AuthResult(
|
|
317
|
+
is_valid=True,
|
|
318
|
+
user_type=token_type,
|
|
319
|
+
remaining_uses=-1,
|
|
320
|
+
message="Limited token with unlimited uses",
|
|
321
|
+
user_email=token_user_email,
|
|
322
|
+
is_admin=token_is_admin,
|
|
323
|
+
api_key_id=api_key_id
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
current_uses = token_data.get("usage_count", 0)
|
|
327
|
+
remaining = max_uses - current_uses
|
|
328
|
+
|
|
329
|
+
if remaining <= 0:
|
|
330
|
+
return AuthResult(
|
|
331
|
+
is_valid=False,
|
|
332
|
+
user_type=token_type,
|
|
333
|
+
remaining_uses=0,
|
|
334
|
+
message="Token usage limit exceeded",
|
|
335
|
+
user_email=token_user_email,
|
|
336
|
+
api_key_id=api_key_id
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
return AuthResult(
|
|
340
|
+
is_valid=True,
|
|
341
|
+
user_type=token_type,
|
|
342
|
+
remaining_uses=remaining,
|
|
343
|
+
message=f"Limited token: {remaining} uses remaining",
|
|
344
|
+
user_email=token_user_email,
|
|
345
|
+
is_admin=token_is_admin,
|
|
346
|
+
api_key_id=api_key_id
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# STRIPE tokens: check expiration and usage
|
|
350
|
+
if token_type == UserType.STRIPE:
|
|
351
|
+
expires_at = token_data.get("expires_at")
|
|
352
|
+
|
|
353
|
+
if expires_at and time.time() > expires_at:
|
|
354
|
+
return AuthResult(
|
|
355
|
+
is_valid=False,
|
|
356
|
+
user_type=token_type,
|
|
357
|
+
remaining_uses=0,
|
|
358
|
+
message="Token has expired",
|
|
359
|
+
user_email=token_user_email,
|
|
360
|
+
api_key_id=api_key_id
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
if max_uses > 0:
|
|
364
|
+
current_uses = token_data.get("usage_count", 0)
|
|
365
|
+
remaining = max_uses - current_uses
|
|
366
|
+
|
|
367
|
+
if remaining <= 0:
|
|
368
|
+
return AuthResult(
|
|
369
|
+
is_valid=False,
|
|
370
|
+
user_type=token_type,
|
|
371
|
+
remaining_uses=0,
|
|
372
|
+
message="Token usage limit exceeded",
|
|
373
|
+
user_email=token_user_email,
|
|
374
|
+
api_key_id=api_key_id
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
return AuthResult(
|
|
378
|
+
is_valid=True,
|
|
379
|
+
user_type=token_type,
|
|
380
|
+
remaining_uses=remaining,
|
|
381
|
+
message=f"Stripe token: {remaining} uses remaining",
|
|
382
|
+
user_email=token_user_email,
|
|
383
|
+
is_admin=token_is_admin,
|
|
384
|
+
api_key_id=api_key_id
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
return AuthResult(
|
|
388
|
+
is_valid=True,
|
|
389
|
+
user_type=token_type,
|
|
390
|
+
remaining_uses=-1,
|
|
391
|
+
message="Stripe access granted",
|
|
392
|
+
user_email=token_user_email,
|
|
393
|
+
is_admin=token_is_admin,
|
|
394
|
+
api_key_id=api_key_id
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# API_KEY tokens
|
|
398
|
+
if token_type == UserType.API_KEY:
|
|
399
|
+
if max_uses > 0:
|
|
400
|
+
current_uses = token_data.get("usage_count", 0)
|
|
401
|
+
remaining = max_uses - current_uses
|
|
402
|
+
if remaining <= 0:
|
|
403
|
+
return AuthResult(
|
|
404
|
+
is_valid=False,
|
|
405
|
+
user_type=token_type,
|
|
406
|
+
remaining_uses=0,
|
|
407
|
+
message="API key usage limit exceeded",
|
|
408
|
+
user_email=token_user_email,
|
|
409
|
+
api_key_id=api_key_id
|
|
410
|
+
)
|
|
411
|
+
return AuthResult(
|
|
412
|
+
is_valid=True,
|
|
413
|
+
user_type=token_type,
|
|
414
|
+
remaining_uses=remaining,
|
|
415
|
+
message=f"API key valid: {remaining} uses remaining",
|
|
416
|
+
user_email=token_user_email,
|
|
417
|
+
is_admin=token_is_admin,
|
|
418
|
+
api_key_id=api_key_id
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
return AuthResult(
|
|
422
|
+
is_valid=True,
|
|
423
|
+
user_type=token_type,
|
|
424
|
+
remaining_uses=-1,
|
|
425
|
+
message="API key access granted",
|
|
426
|
+
user_email=token_user_email,
|
|
427
|
+
is_admin=token_is_admin,
|
|
428
|
+
api_key_id=api_key_id
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
return AuthResult(
|
|
432
|
+
is_valid=False,
|
|
433
|
+
user_type=UserType.LIMITED,
|
|
434
|
+
remaining_uses=0,
|
|
435
|
+
message="Unknown token type"
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def increment_token_usage(self, token: str, job_id: str) -> bool:
|
|
439
|
+
"""
|
|
440
|
+
Increment usage count for a token and track the job.
|
|
441
|
+
|
|
442
|
+
For session tokens (magic link auth), this deducts a credit from the user.
|
|
443
|
+
For auth_tokens, this increments the usage count.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
token: The access token
|
|
447
|
+
job_id: The job ID being created
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
True if usage was tracked, False otherwise
|
|
451
|
+
"""
|
|
452
|
+
# Validate token first
|
|
453
|
+
is_valid, user_type, remaining_uses, message = self.validate_token(token)
|
|
454
|
+
|
|
455
|
+
if not is_valid:
|
|
456
|
+
logger.warning(f"Cannot increment usage for invalid token: {message}")
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
# Don't track usage for admin or unlimited tokens
|
|
460
|
+
if user_type in [UserType.ADMIN, UserType.UNLIMITED]:
|
|
461
|
+
logger.debug(f"Skipping usage tracking for {user_type} token")
|
|
462
|
+
return True
|
|
463
|
+
|
|
464
|
+
# For admin tokens (not in Firestore), no tracking needed
|
|
465
|
+
if token in self.admin_tokens:
|
|
466
|
+
return True
|
|
467
|
+
|
|
468
|
+
# Check if this is a session token (STRIPE type from validate_token means it's a session)
|
|
469
|
+
# Session tokens are not in auth_tokens collection
|
|
470
|
+
token_data = self.firestore.get_token(token)
|
|
471
|
+
|
|
472
|
+
if not token_data:
|
|
473
|
+
# This is a session token - deduct credit from user
|
|
474
|
+
from backend.services.user_service import get_user_service
|
|
475
|
+
user_service = get_user_service()
|
|
476
|
+
|
|
477
|
+
# Get the user email from the session
|
|
478
|
+
is_valid, user, _ = user_service.validate_session(token)
|
|
479
|
+
if not is_valid or not user:
|
|
480
|
+
logger.error(f"Session token validation failed during usage increment")
|
|
481
|
+
return False
|
|
482
|
+
|
|
483
|
+
# Deduct one credit
|
|
484
|
+
success, new_balance, deduct_message = user_service.deduct_credit(
|
|
485
|
+
user.email, job_id, reason="job_creation"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
if success:
|
|
489
|
+
logger.info(f"Deducted credit for user {user.email} (remaining: {new_balance})")
|
|
490
|
+
return True
|
|
491
|
+
else:
|
|
492
|
+
logger.error(f"Failed to deduct credit for user {user.email}: {deduct_message}")
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
# Regular auth_token - increment usage in Firestore
|
|
496
|
+
try:
|
|
497
|
+
self.firestore.increment_token_usage(token, job_id)
|
|
498
|
+
logger.info(f"Incremented usage for token (remaining: {remaining_uses - 1})")
|
|
499
|
+
return True
|
|
500
|
+
except Exception as e:
|
|
501
|
+
logger.error(f"Failed to increment token usage: {e}")
|
|
502
|
+
return False
|
|
503
|
+
|
|
504
|
+
def create_token(
|
|
505
|
+
self,
|
|
506
|
+
token_value: str,
|
|
507
|
+
token_type: UserType,
|
|
508
|
+
max_uses: int = -1,
|
|
509
|
+
expires_at: Optional[float] = None,
|
|
510
|
+
created_by: Optional[str] = None
|
|
511
|
+
) -> Dict[str, Any]:
|
|
512
|
+
"""
|
|
513
|
+
Create a new access token.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
token_value: The actual token string (should be secure random string)
|
|
517
|
+
token_type: Type of access (admin, unlimited, limited, stripe)
|
|
518
|
+
max_uses: Maximum number of uses (-1 = unlimited)
|
|
519
|
+
expires_at: Expiration timestamp (None = no expiration)
|
|
520
|
+
created_by: Admin token that created this token
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
Token data dictionary
|
|
524
|
+
"""
|
|
525
|
+
token_data = {
|
|
526
|
+
"token": token_value,
|
|
527
|
+
"type": token_type.value,
|
|
528
|
+
"max_uses": max_uses,
|
|
529
|
+
"usage_count": 0,
|
|
530
|
+
"active": True,
|
|
531
|
+
"created_at": time.time(),
|
|
532
|
+
"created_by": created_by,
|
|
533
|
+
"expires_at": expires_at,
|
|
534
|
+
"jobs": []
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
self.firestore.create_token(token_value, token_data)
|
|
538
|
+
logger.info(f"Created new {token_type} token with max_uses={max_uses}")
|
|
539
|
+
|
|
540
|
+
return token_data
|
|
541
|
+
|
|
542
|
+
def revoke_token(self, token: str, revoked_by: Optional[str] = None) -> bool:
|
|
543
|
+
"""
|
|
544
|
+
Revoke an access token.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
token: The token to revoke
|
|
548
|
+
revoked_by: Admin token that revoked this token
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
True if revoked, False if token not found
|
|
552
|
+
"""
|
|
553
|
+
try:
|
|
554
|
+
self.firestore.update_token(token, {
|
|
555
|
+
"active": False,
|
|
556
|
+
"revoked_at": time.time(),
|
|
557
|
+
"revoked_by": revoked_by
|
|
558
|
+
})
|
|
559
|
+
logger.info(f"Revoked token")
|
|
560
|
+
return True
|
|
561
|
+
except Exception as e:
|
|
562
|
+
logger.error(f"Failed to revoke token: {e}")
|
|
563
|
+
return False
|
|
564
|
+
|
|
565
|
+
def list_tokens(self, include_inactive: bool = False) -> list:
|
|
566
|
+
"""
|
|
567
|
+
List all tokens (admin only).
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
include_inactive: Whether to include revoked tokens
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
List of token data dictionaries (with sensitive data masked)
|
|
574
|
+
"""
|
|
575
|
+
try:
|
|
576
|
+
tokens = self.firestore.list_tokens()
|
|
577
|
+
|
|
578
|
+
# Filter inactive if requested
|
|
579
|
+
if not include_inactive:
|
|
580
|
+
tokens = [t for t in tokens if t.get("active", True)]
|
|
581
|
+
|
|
582
|
+
# Mask token values for security (show only first 8 chars)
|
|
583
|
+
for token in tokens:
|
|
584
|
+
if "token" in token and len(token["token"]) > 8:
|
|
585
|
+
token["token"] = token["token"][:8] + "..."
|
|
586
|
+
|
|
587
|
+
return tokens
|
|
588
|
+
except Exception as e:
|
|
589
|
+
logger.error(f"Failed to list tokens: {e}")
|
|
590
|
+
return []
|
|
591
|
+
|
|
592
|
+
def get_token_info(self, token: str) -> Optional[Dict[str, Any]]:
|
|
593
|
+
"""
|
|
594
|
+
Get information about a token (for admin dashboard).
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
Token data or None if not found
|
|
598
|
+
"""
|
|
599
|
+
# Check if it's an admin token (not stored in Firestore)
|
|
600
|
+
if token in self.admin_tokens:
|
|
601
|
+
return {
|
|
602
|
+
"token": token[:8] + "...",
|
|
603
|
+
"type": UserType.ADMIN.value,
|
|
604
|
+
"max_uses": -1,
|
|
605
|
+
"usage_count": 0,
|
|
606
|
+
"active": True,
|
|
607
|
+
"source": "environment"
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
# Get from Firestore
|
|
611
|
+
token_data = self.firestore.get_token(token)
|
|
612
|
+
|
|
613
|
+
if token_data and "token" in token_data:
|
|
614
|
+
# Mask the full token value
|
|
615
|
+
token_data["token"] = token_data["token"][:8] + "..."
|
|
616
|
+
|
|
617
|
+
return token_data
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# Global instance
|
|
621
|
+
_auth_service = None
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def get_auth_service() -> AuthService:
|
|
625
|
+
"""Get the global auth service instance."""
|
|
626
|
+
global _auth_service
|
|
627
|
+
if _auth_service is None:
|
|
628
|
+
_auth_service = AuthService()
|
|
629
|
+
return _auth_service
|
|
630
|
+
|