karaoke-gen 0.90.1__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.
Files changed (187) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {karaoke_gen-0.90.1.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
+