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.
- 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/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
- 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.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
"""
|
|
2
|
+
User service for authentication, credits, and user management.
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- Magic link authentication (send, verify)
|
|
6
|
+
- Session management
|
|
7
|
+
- Credit operations (add, deduct, refund)
|
|
8
|
+
- User CRUD operations
|
|
9
|
+
"""
|
|
10
|
+
import logging
|
|
11
|
+
import secrets
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
from typing import Optional, List, Tuple
|
|
15
|
+
|
|
16
|
+
from google.cloud import firestore
|
|
17
|
+
from google.cloud.firestore_v1 import FieldFilter
|
|
18
|
+
from google.cloud.firestore_v1 import Increment
|
|
19
|
+
|
|
20
|
+
from backend.config import get_settings
|
|
21
|
+
from backend.models.user import (
|
|
22
|
+
User,
|
|
23
|
+
UserRole,
|
|
24
|
+
UserPublic,
|
|
25
|
+
MagicLinkToken,
|
|
26
|
+
Session,
|
|
27
|
+
CreditTransaction,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Collection names
|
|
35
|
+
USERS_COLLECTION = "gen_users"
|
|
36
|
+
MAGIC_LINKS_COLLECTION = "magic_links"
|
|
37
|
+
SESSIONS_COLLECTION = "sessions"
|
|
38
|
+
PROCESSED_STRIPE_SESSIONS_COLLECTION = "processed_stripe_sessions"
|
|
39
|
+
|
|
40
|
+
# Token/session configuration
|
|
41
|
+
MAGIC_LINK_EXPIRY_MINUTES = 15
|
|
42
|
+
SESSION_EXPIRY_DAYS = 7
|
|
43
|
+
SESSION_ABSOLUTE_EXPIRY_DAYS = 30
|
|
44
|
+
MAX_CREDIT_TRANSACTIONS = 100 # Keep last N transactions
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UserService:
|
|
48
|
+
"""Service for user management and authentication."""
|
|
49
|
+
|
|
50
|
+
# Number of free credits granted to new users
|
|
51
|
+
NEW_USER_FREE_CREDITS = 1
|
|
52
|
+
|
|
53
|
+
def __init__(self):
|
|
54
|
+
"""Initialize user service with Firestore client."""
|
|
55
|
+
self.settings = get_settings()
|
|
56
|
+
self.db = firestore.Client(project=self.settings.google_cloud_project)
|
|
57
|
+
|
|
58
|
+
# =========================================================================
|
|
59
|
+
# User CRUD Operations
|
|
60
|
+
# =========================================================================
|
|
61
|
+
|
|
62
|
+
def get_user(self, email: str) -> Optional[User]:
|
|
63
|
+
"""Get a user by email."""
|
|
64
|
+
try:
|
|
65
|
+
doc_ref = self.db.collection(USERS_COLLECTION).document(email.lower())
|
|
66
|
+
doc = doc_ref.get()
|
|
67
|
+
|
|
68
|
+
if not doc.exists:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
return User(**doc.to_dict())
|
|
72
|
+
except Exception:
|
|
73
|
+
logger.exception(f"Error getting user {email}")
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def get_or_create_user(self, email: str) -> User:
|
|
77
|
+
"""
|
|
78
|
+
Get existing user or create a new one.
|
|
79
|
+
|
|
80
|
+
New users receive a welcome credit to try the service.
|
|
81
|
+
"""
|
|
82
|
+
email = email.lower()
|
|
83
|
+
user = self.get_user(email)
|
|
84
|
+
|
|
85
|
+
if user:
|
|
86
|
+
return user
|
|
87
|
+
|
|
88
|
+
# Create new user with welcome credit
|
|
89
|
+
welcome_credit = self.NEW_USER_FREE_CREDITS
|
|
90
|
+
welcome_transaction = CreditTransaction(
|
|
91
|
+
id=str(uuid.uuid4()),
|
|
92
|
+
amount=welcome_credit,
|
|
93
|
+
reason="welcome_credit",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
user = User(
|
|
97
|
+
email=email,
|
|
98
|
+
credits=welcome_credit,
|
|
99
|
+
credit_transactions=[welcome_transaction],
|
|
100
|
+
)
|
|
101
|
+
self._save_user(user)
|
|
102
|
+
logger.info(f"Created new user: {email} with {welcome_credit} welcome credit(s)")
|
|
103
|
+
return user
|
|
104
|
+
|
|
105
|
+
def _save_user(self, user: User) -> None:
|
|
106
|
+
"""Save user to Firestore."""
|
|
107
|
+
try:
|
|
108
|
+
user.updated_at = datetime.utcnow()
|
|
109
|
+
doc_ref = self.db.collection(USERS_COLLECTION).document(user.email.lower())
|
|
110
|
+
doc_ref.set(user.model_dump(mode='json'))
|
|
111
|
+
except Exception:
|
|
112
|
+
logger.exception(f"Error saving user {user.email}")
|
|
113
|
+
raise
|
|
114
|
+
|
|
115
|
+
def update_user(self, email: str, **updates) -> Optional[User]:
|
|
116
|
+
"""Update user fields."""
|
|
117
|
+
try:
|
|
118
|
+
updates['updated_at'] = datetime.utcnow()
|
|
119
|
+
doc_ref = self.db.collection(USERS_COLLECTION).document(email.lower())
|
|
120
|
+
doc_ref.update(updates)
|
|
121
|
+
return self.get_user(email)
|
|
122
|
+
except Exception:
|
|
123
|
+
logger.exception(f"Error updating user {email}")
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def list_users(self, limit: int = 100, include_inactive: bool = False) -> List[User]:
|
|
127
|
+
"""List all users (admin only)."""
|
|
128
|
+
try:
|
|
129
|
+
query = self.db.collection(USERS_COLLECTION)
|
|
130
|
+
|
|
131
|
+
if not include_inactive:
|
|
132
|
+
query = query.where(filter=FieldFilter('is_active', '==', True))
|
|
133
|
+
|
|
134
|
+
query = query.order_by('created_at', direction=firestore.Query.DESCENDING)
|
|
135
|
+
query = query.limit(limit)
|
|
136
|
+
|
|
137
|
+
docs = query.stream()
|
|
138
|
+
return [User(**doc.to_dict()) for doc in docs]
|
|
139
|
+
except Exception:
|
|
140
|
+
logger.exception("Error listing users")
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
def get_user_public(self, email: str) -> Optional[UserPublic]:
|
|
144
|
+
"""Get public user info."""
|
|
145
|
+
user = self.get_user(email)
|
|
146
|
+
if not user:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
return UserPublic(
|
|
150
|
+
email=user.email,
|
|
151
|
+
role=user.role,
|
|
152
|
+
credits=user.credits,
|
|
153
|
+
display_name=user.display_name,
|
|
154
|
+
total_jobs_created=user.total_jobs_created,
|
|
155
|
+
total_jobs_completed=user.total_jobs_completed,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# =========================================================================
|
|
159
|
+
# Magic Link Authentication
|
|
160
|
+
# =========================================================================
|
|
161
|
+
|
|
162
|
+
def create_magic_link(
|
|
163
|
+
self,
|
|
164
|
+
email: str,
|
|
165
|
+
ip_address: Optional[str] = None,
|
|
166
|
+
user_agent: Optional[str] = None
|
|
167
|
+
) -> MagicLinkToken:
|
|
168
|
+
"""
|
|
169
|
+
Create a magic link token for email authentication.
|
|
170
|
+
|
|
171
|
+
Returns the token object. The actual sending of the email
|
|
172
|
+
should be handled by the caller (or an email service).
|
|
173
|
+
"""
|
|
174
|
+
email = email.lower()
|
|
175
|
+
|
|
176
|
+
# Ensure user exists
|
|
177
|
+
self.get_or_create_user(email)
|
|
178
|
+
|
|
179
|
+
# Generate secure token
|
|
180
|
+
token = secrets.token_urlsafe(32)
|
|
181
|
+
|
|
182
|
+
magic_link = MagicLinkToken(
|
|
183
|
+
token=token,
|
|
184
|
+
email=email,
|
|
185
|
+
expires_at=datetime.utcnow() + timedelta(minutes=MAGIC_LINK_EXPIRY_MINUTES),
|
|
186
|
+
ip_address=ip_address,
|
|
187
|
+
user_agent=user_agent,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Save to Firestore
|
|
191
|
+
doc_ref = self.db.collection(MAGIC_LINKS_COLLECTION).document(token)
|
|
192
|
+
doc_ref.set(magic_link.model_dump(mode='json'))
|
|
193
|
+
|
|
194
|
+
logger.info(f"Created magic link for {email}")
|
|
195
|
+
return magic_link
|
|
196
|
+
|
|
197
|
+
def verify_magic_link(self, token: str) -> Tuple[bool, Optional[User], str]:
|
|
198
|
+
"""
|
|
199
|
+
Verify a magic link token using a Firestore transaction to prevent race conditions.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
(success, user, message)
|
|
203
|
+
"""
|
|
204
|
+
try:
|
|
205
|
+
doc_ref = self.db.collection(MAGIC_LINKS_COLLECTION).document(token)
|
|
206
|
+
|
|
207
|
+
@firestore.transactional
|
|
208
|
+
def verify_in_transaction(transaction):
|
|
209
|
+
"""Atomically verify and mark magic link as used."""
|
|
210
|
+
doc = doc_ref.get(transaction=transaction)
|
|
211
|
+
|
|
212
|
+
if not doc.exists:
|
|
213
|
+
return False, None, "Invalid or expired link"
|
|
214
|
+
|
|
215
|
+
magic_link = MagicLinkToken(**doc.to_dict())
|
|
216
|
+
|
|
217
|
+
# Check if already used
|
|
218
|
+
if magic_link.used:
|
|
219
|
+
return False, None, "This link has already been used"
|
|
220
|
+
|
|
221
|
+
# Check expiry
|
|
222
|
+
if datetime.utcnow() > magic_link.expires_at:
|
|
223
|
+
return False, None, "This link has expired"
|
|
224
|
+
|
|
225
|
+
# Mark as used atomically within transaction
|
|
226
|
+
transaction.update(doc_ref, {
|
|
227
|
+
'used': True,
|
|
228
|
+
'used_at': datetime.utcnow()
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
return True, magic_link.email, "Success"
|
|
232
|
+
|
|
233
|
+
# Execute the transaction
|
|
234
|
+
transaction = self.db.transaction()
|
|
235
|
+
success, email_or_error, message = verify_in_transaction(transaction)
|
|
236
|
+
|
|
237
|
+
if not success:
|
|
238
|
+
return False, None, message
|
|
239
|
+
|
|
240
|
+
# Get user and mark email as verified (outside transaction)
|
|
241
|
+
user = self.get_user(email_or_error)
|
|
242
|
+
if user:
|
|
243
|
+
self.update_user(
|
|
244
|
+
email_or_error,
|
|
245
|
+
email_verified=True,
|
|
246
|
+
last_login_at=datetime.utcnow()
|
|
247
|
+
)
|
|
248
|
+
user = self.get_user(email_or_error) # Refresh
|
|
249
|
+
|
|
250
|
+
logger.info(f"Magic link verified for {email_or_error}")
|
|
251
|
+
return True, user, "Successfully authenticated"
|
|
252
|
+
|
|
253
|
+
except Exception:
|
|
254
|
+
logger.exception("Error verifying magic link")
|
|
255
|
+
return False, None, "An error occurred during verification"
|
|
256
|
+
|
|
257
|
+
# =========================================================================
|
|
258
|
+
# Session Management
|
|
259
|
+
# =========================================================================
|
|
260
|
+
|
|
261
|
+
def create_session(
|
|
262
|
+
self,
|
|
263
|
+
user_email: str,
|
|
264
|
+
ip_address: Optional[str] = None,
|
|
265
|
+
user_agent: Optional[str] = None
|
|
266
|
+
) -> Session:
|
|
267
|
+
"""Create a new session for an authenticated user."""
|
|
268
|
+
token = secrets.token_urlsafe(32)
|
|
269
|
+
token_prefix = token[:12]
|
|
270
|
+
|
|
271
|
+
session = Session(
|
|
272
|
+
token=token,
|
|
273
|
+
user_email=user_email.lower(),
|
|
274
|
+
expires_at=datetime.utcnow() + timedelta(days=SESSION_ABSOLUTE_EXPIRY_DAYS),
|
|
275
|
+
ip_address=ip_address,
|
|
276
|
+
user_agent=user_agent,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Serialize and write to Firestore
|
|
280
|
+
session_data = session.model_dump(mode='json')
|
|
281
|
+
doc_ref = self.db.collection(SESSIONS_COLLECTION).document(token)
|
|
282
|
+
doc_ref.set(session_data)
|
|
283
|
+
|
|
284
|
+
# Verify the write succeeded by reading back
|
|
285
|
+
verify_doc = doc_ref.get()
|
|
286
|
+
if not verify_doc.exists:
|
|
287
|
+
logger.error(f"Session write verification FAILED for {user_email}: {token_prefix}...")
|
|
288
|
+
raise RuntimeError(f"Failed to persist session for {user_email}")
|
|
289
|
+
|
|
290
|
+
logger.info(f"Created and verified session for {user_email}: {token_prefix}... (expires: {session.expires_at})")
|
|
291
|
+
|
|
292
|
+
return session
|
|
293
|
+
|
|
294
|
+
def validate_session(self, token: str) -> Tuple[bool, Optional[User], str]:
|
|
295
|
+
"""
|
|
296
|
+
Validate a session token.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
(valid, user, message)
|
|
300
|
+
"""
|
|
301
|
+
# Log token info for debugging (only prefix for security)
|
|
302
|
+
token_prefix = token[:12] if token and len(token) >= 12 else token
|
|
303
|
+
logger.debug(f"Validating session token: {token_prefix}... (len={len(token) if token else 0})")
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
doc_ref = self.db.collection(SESSIONS_COLLECTION).document(token)
|
|
307
|
+
doc = doc_ref.get()
|
|
308
|
+
|
|
309
|
+
if not doc.exists:
|
|
310
|
+
logger.warning(f"Session not found in Firestore: {token_prefix}...")
|
|
311
|
+
return False, None, "Invalid session"
|
|
312
|
+
|
|
313
|
+
raw_data = doc.to_dict()
|
|
314
|
+
logger.debug(f"Session data found for {token_prefix}...: user_email={raw_data.get('user_email')}, is_active={raw_data.get('is_active')}")
|
|
315
|
+
|
|
316
|
+
session = Session(**raw_data)
|
|
317
|
+
|
|
318
|
+
# Check if active
|
|
319
|
+
if not session.is_active:
|
|
320
|
+
logger.warning(f"Session revoked for {token_prefix}... (user: {session.user_email})")
|
|
321
|
+
return False, None, "Session has been revoked"
|
|
322
|
+
|
|
323
|
+
# Use timezone-aware datetime for all comparisons
|
|
324
|
+
# Firestore returns timezone-aware datetimes, so we must use aware datetimes
|
|
325
|
+
now = datetime.now(timezone.utc)
|
|
326
|
+
|
|
327
|
+
# Normalize session datetimes to be timezone-aware for comparison
|
|
328
|
+
# (handles legacy naive datetimes that may exist in Firestore)
|
|
329
|
+
expires_at = session.expires_at
|
|
330
|
+
if expires_at.tzinfo is None:
|
|
331
|
+
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
332
|
+
|
|
333
|
+
last_activity = session.last_activity_at
|
|
334
|
+
if last_activity.tzinfo is None:
|
|
335
|
+
last_activity = last_activity.replace(tzinfo=timezone.utc)
|
|
336
|
+
|
|
337
|
+
# Check expiry
|
|
338
|
+
if now > expires_at:
|
|
339
|
+
logger.warning(f"Session expired for {token_prefix}... (user: {session.user_email}, expired_at: {expires_at}, now: {now})")
|
|
340
|
+
return False, None, "Session has expired"
|
|
341
|
+
|
|
342
|
+
# Check inactivity (7 days)
|
|
343
|
+
inactivity_limit = now - timedelta(days=SESSION_EXPIRY_DAYS)
|
|
344
|
+
if last_activity < inactivity_limit:
|
|
345
|
+
logger.warning(f"Session inactive for {token_prefix}... (user: {session.user_email}, last_activity: {last_activity}, limit: {inactivity_limit})")
|
|
346
|
+
return False, None, "Session expired due to inactivity"
|
|
347
|
+
|
|
348
|
+
# Update last activity (use timezone-aware datetime)
|
|
349
|
+
doc_ref.update({'last_activity_at': now})
|
|
350
|
+
|
|
351
|
+
# Get user
|
|
352
|
+
user = self.get_user(session.user_email)
|
|
353
|
+
if not user:
|
|
354
|
+
logger.warning(f"User not found for session {token_prefix}...: {session.user_email}")
|
|
355
|
+
return False, None, "User not found"
|
|
356
|
+
|
|
357
|
+
if not user.is_active:
|
|
358
|
+
logger.warning(f"User account disabled for session {token_prefix}...: {session.user_email}")
|
|
359
|
+
return False, None, "User account is disabled"
|
|
360
|
+
|
|
361
|
+
logger.debug(f"Session valid for {token_prefix}... (user: {user.email}, credits: {user.credits})")
|
|
362
|
+
return True, user, "Valid session"
|
|
363
|
+
|
|
364
|
+
except Exception:
|
|
365
|
+
logger.exception(f"Error validating session {token_prefix}...")
|
|
366
|
+
return False, None, "An error occurred during validation"
|
|
367
|
+
|
|
368
|
+
def revoke_session(self, token: str) -> bool:
|
|
369
|
+
"""Revoke a session (logout)."""
|
|
370
|
+
try:
|
|
371
|
+
doc_ref = self.db.collection(SESSIONS_COLLECTION).document(token)
|
|
372
|
+
doc_ref.update({'is_active': False})
|
|
373
|
+
logger.info("Session revoked")
|
|
374
|
+
return True
|
|
375
|
+
except Exception:
|
|
376
|
+
logger.exception("Error revoking session")
|
|
377
|
+
return False
|
|
378
|
+
|
|
379
|
+
def revoke_all_sessions(self, user_email: str) -> int:
|
|
380
|
+
"""Revoke all sessions for a user."""
|
|
381
|
+
try:
|
|
382
|
+
query = self.db.collection(SESSIONS_COLLECTION).where(
|
|
383
|
+
filter=FieldFilter('user_email', '==', user_email.lower())
|
|
384
|
+
).where(
|
|
385
|
+
filter=FieldFilter('is_active', '==', True)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
count = 0
|
|
389
|
+
for doc in query.stream():
|
|
390
|
+
doc.reference.update({'is_active': False})
|
|
391
|
+
count += 1
|
|
392
|
+
|
|
393
|
+
logger.info(f"Revoked {count} sessions for {user_email}")
|
|
394
|
+
return count
|
|
395
|
+
except Exception:
|
|
396
|
+
logger.exception("Error revoking sessions")
|
|
397
|
+
return 0
|
|
398
|
+
|
|
399
|
+
def list_sessions_for_user(
|
|
400
|
+
self,
|
|
401
|
+
user_email: str,
|
|
402
|
+
include_revoked: bool = False,
|
|
403
|
+
limit: int = 50
|
|
404
|
+
) -> List[Session]:
|
|
405
|
+
"""
|
|
406
|
+
List all sessions for a user.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
user_email: User's email address
|
|
410
|
+
include_revoked: If True, include revoked/inactive sessions
|
|
411
|
+
limit: Maximum number of sessions to return
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
List of Session objects, ordered by created_at descending
|
|
415
|
+
"""
|
|
416
|
+
try:
|
|
417
|
+
query = self.db.collection(SESSIONS_COLLECTION).where(
|
|
418
|
+
filter=FieldFilter('user_email', '==', user_email.lower())
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
if not include_revoked:
|
|
422
|
+
query = query.where(filter=FieldFilter('is_active', '==', True))
|
|
423
|
+
|
|
424
|
+
query = query.order_by('created_at', direction=firestore.Query.DESCENDING)
|
|
425
|
+
query = query.limit(limit)
|
|
426
|
+
|
|
427
|
+
sessions = []
|
|
428
|
+
for doc in query.stream():
|
|
429
|
+
try:
|
|
430
|
+
sessions.append(Session(**doc.to_dict()))
|
|
431
|
+
except Exception as e:
|
|
432
|
+
logger.warning(f"Failed to parse session document: {e}")
|
|
433
|
+
|
|
434
|
+
logger.debug(f"Found {len(sessions)} sessions for {user_email}")
|
|
435
|
+
return sessions
|
|
436
|
+
except Exception:
|
|
437
|
+
logger.exception(f"Error listing sessions for {user_email}")
|
|
438
|
+
return []
|
|
439
|
+
|
|
440
|
+
# =========================================================================
|
|
441
|
+
# Credit Operations
|
|
442
|
+
# =========================================================================
|
|
443
|
+
|
|
444
|
+
def add_credits(
|
|
445
|
+
self,
|
|
446
|
+
email: str,
|
|
447
|
+
amount: int,
|
|
448
|
+
reason: str,
|
|
449
|
+
job_id: Optional[str] = None,
|
|
450
|
+
stripe_session_id: Optional[str] = None,
|
|
451
|
+
admin_email: Optional[str] = None
|
|
452
|
+
) -> Tuple[bool, int, str]:
|
|
453
|
+
"""
|
|
454
|
+
Add credits to a user account.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
(success, new_balance, message)
|
|
458
|
+
"""
|
|
459
|
+
try:
|
|
460
|
+
# For Stripe sessions, atomically check and mark as processed
|
|
461
|
+
# Uses create() which fails if document exists - ensures idempotency
|
|
462
|
+
if stripe_session_id:
|
|
463
|
+
doc_ref = self.db.collection(PROCESSED_STRIPE_SESSIONS_COLLECTION).document(stripe_session_id)
|
|
464
|
+
try:
|
|
465
|
+
doc_ref.create({
|
|
466
|
+
'stripe_session_id': stripe_session_id,
|
|
467
|
+
'email': email.lower(),
|
|
468
|
+
'amount': amount,
|
|
469
|
+
'processed_at': datetime.utcnow()
|
|
470
|
+
})
|
|
471
|
+
except Exception:
|
|
472
|
+
# Document already exists - this session was already processed
|
|
473
|
+
logger.info(f"Stripe session {stripe_session_id} already processed (idempotent skip)")
|
|
474
|
+
return False, 0, "Session already processed"
|
|
475
|
+
|
|
476
|
+
email = email.lower()
|
|
477
|
+
user = self.get_or_create_user(email)
|
|
478
|
+
|
|
479
|
+
# Create transaction record
|
|
480
|
+
transaction = CreditTransaction(
|
|
481
|
+
id=str(uuid.uuid4()),
|
|
482
|
+
amount=amount,
|
|
483
|
+
reason=reason,
|
|
484
|
+
job_id=job_id,
|
|
485
|
+
stripe_session_id=stripe_session_id,
|
|
486
|
+
created_by=admin_email,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# Add to transaction history (keep last N)
|
|
490
|
+
transactions = user.credit_transactions[-MAX_CREDIT_TRANSACTIONS + 1:]
|
|
491
|
+
transactions.append(transaction)
|
|
492
|
+
|
|
493
|
+
# Update user
|
|
494
|
+
new_balance = user.credits + amount
|
|
495
|
+
self.update_user(
|
|
496
|
+
email,
|
|
497
|
+
credits=new_balance,
|
|
498
|
+
credit_transactions=[t.model_dump(mode='json') for t in transactions]
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
logger.info(f"Added {amount} credits to {email} ({reason}). New balance: {new_balance}")
|
|
502
|
+
return True, new_balance, f"Added {amount} credits"
|
|
503
|
+
|
|
504
|
+
except Exception as e:
|
|
505
|
+
logger.exception(f"Error adding credits to {email}")
|
|
506
|
+
return False, 0, f"Failed to add credits: {e}"
|
|
507
|
+
|
|
508
|
+
def deduct_credit(
|
|
509
|
+
self,
|
|
510
|
+
email: str,
|
|
511
|
+
job_id: str,
|
|
512
|
+
reason: str = "job_creation"
|
|
513
|
+
) -> Tuple[bool, int, str]:
|
|
514
|
+
"""
|
|
515
|
+
Deduct one credit from a user account.
|
|
516
|
+
|
|
517
|
+
Uses Firestore transaction to prevent race conditions.
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
(success, remaining_credits, message)
|
|
521
|
+
"""
|
|
522
|
+
try:
|
|
523
|
+
email = email.lower()
|
|
524
|
+
doc_ref = self.db.collection(USERS_COLLECTION).document(email)
|
|
525
|
+
|
|
526
|
+
@firestore.transactional
|
|
527
|
+
def deduct_in_transaction(transaction):
|
|
528
|
+
"""Atomically check and deduct credit."""
|
|
529
|
+
doc = doc_ref.get(transaction=transaction)
|
|
530
|
+
|
|
531
|
+
if not doc.exists:
|
|
532
|
+
return False, 0, "User not found"
|
|
533
|
+
|
|
534
|
+
user_data = doc.to_dict()
|
|
535
|
+
current_credits = user_data.get('credits', 0)
|
|
536
|
+
|
|
537
|
+
if current_credits <= 0:
|
|
538
|
+
return False, 0, "Insufficient credits"
|
|
539
|
+
|
|
540
|
+
# Create transaction record
|
|
541
|
+
credit_txn = CreditTransaction(
|
|
542
|
+
id=str(uuid.uuid4()),
|
|
543
|
+
amount=-1,
|
|
544
|
+
reason=reason,
|
|
545
|
+
job_id=job_id,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Get existing transactions and add new one
|
|
549
|
+
existing_transactions = user_data.get('credit_transactions', [])
|
|
550
|
+
# Keep last N-1 transactions to make room for new one
|
|
551
|
+
transactions = existing_transactions[-(MAX_CREDIT_TRANSACTIONS - 1):]
|
|
552
|
+
transactions.append(credit_txn.model_dump(mode='json'))
|
|
553
|
+
|
|
554
|
+
# Calculate new values
|
|
555
|
+
new_balance = current_credits - 1
|
|
556
|
+
total_jobs = user_data.get('total_jobs_created', 0) + 1
|
|
557
|
+
|
|
558
|
+
# Update atomically within transaction
|
|
559
|
+
transaction.update(doc_ref, {
|
|
560
|
+
'credits': new_balance,
|
|
561
|
+
'credit_transactions': transactions,
|
|
562
|
+
'total_jobs_created': total_jobs,
|
|
563
|
+
'updated_at': datetime.utcnow()
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
return True, new_balance, f"Credit deducted. {new_balance} remaining"
|
|
567
|
+
|
|
568
|
+
# Execute the transaction
|
|
569
|
+
fs_transaction = self.db.transaction()
|
|
570
|
+
success, new_balance, message = deduct_in_transaction(fs_transaction)
|
|
571
|
+
|
|
572
|
+
if success:
|
|
573
|
+
logger.info(f"Deducted 1 credit from {email} for job {job_id}. Remaining: {new_balance}")
|
|
574
|
+
|
|
575
|
+
return success, new_balance, message
|
|
576
|
+
|
|
577
|
+
except Exception as e:
|
|
578
|
+
logger.exception(f"Error deducting credit from {email}")
|
|
579
|
+
return False, 0, f"Failed to deduct credit: {e}"
|
|
580
|
+
|
|
581
|
+
def refund_credit(
|
|
582
|
+
self,
|
|
583
|
+
email: str,
|
|
584
|
+
job_id: str,
|
|
585
|
+
reason: str = "job_failed"
|
|
586
|
+
) -> Tuple[bool, int, str]:
|
|
587
|
+
"""
|
|
588
|
+
Refund one credit to a user account (e.g., after job failure).
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
(success, new_balance, message)
|
|
592
|
+
"""
|
|
593
|
+
return self.add_credits(email, 1, reason, job_id=job_id)
|
|
594
|
+
|
|
595
|
+
def check_credits(self, email: str) -> int:
|
|
596
|
+
"""Check user's credit balance."""
|
|
597
|
+
user = self.get_user(email)
|
|
598
|
+
return user.credits if user else 0
|
|
599
|
+
|
|
600
|
+
def has_credits(self, email: str) -> bool:
|
|
601
|
+
"""Check if user has at least one credit."""
|
|
602
|
+
return self.check_credits(email) > 0
|
|
603
|
+
|
|
604
|
+
def is_stripe_session_processed(self, stripe_session_id: str) -> bool:
|
|
605
|
+
"""
|
|
606
|
+
Check if a Stripe session ID has already been processed.
|
|
607
|
+
|
|
608
|
+
Used to ensure idempotency of webhook processing - prevents
|
|
609
|
+
duplicate credit additions if Stripe sends the same webhook twice.
|
|
610
|
+
|
|
611
|
+
Uses a dedicated collection for O(1) lookup instead of scanning all users.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
stripe_session_id: The Stripe checkout session ID
|
|
615
|
+
|
|
616
|
+
Returns:
|
|
617
|
+
True if this session was already processed
|
|
618
|
+
"""
|
|
619
|
+
try:
|
|
620
|
+
doc_ref = self.db.collection(PROCESSED_STRIPE_SESSIONS_COLLECTION).document(stripe_session_id)
|
|
621
|
+
doc = doc_ref.get()
|
|
622
|
+
if doc.exists:
|
|
623
|
+
logger.info(f"Stripe session {stripe_session_id} already processed")
|
|
624
|
+
return True
|
|
625
|
+
return False
|
|
626
|
+
except Exception:
|
|
627
|
+
logger.exception(f"Error checking if stripe session {stripe_session_id} was processed")
|
|
628
|
+
# On error, return False to allow processing (better to risk duplicate than block)
|
|
629
|
+
return False
|
|
630
|
+
|
|
631
|
+
def _mark_stripe_session_processed(self, stripe_session_id: str, email: str, amount: int) -> None:
|
|
632
|
+
"""
|
|
633
|
+
Mark a Stripe session as processed.
|
|
634
|
+
|
|
635
|
+
Called after successfully adding credits from a Stripe webhook.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
stripe_session_id: The Stripe checkout session ID
|
|
639
|
+
email: User email who received the credits
|
|
640
|
+
amount: Number of credits added
|
|
641
|
+
"""
|
|
642
|
+
try:
|
|
643
|
+
doc_ref = self.db.collection(PROCESSED_STRIPE_SESSIONS_COLLECTION).document(stripe_session_id)
|
|
644
|
+
doc_ref.set({
|
|
645
|
+
'stripe_session_id': stripe_session_id,
|
|
646
|
+
'email': email,
|
|
647
|
+
'amount': amount,
|
|
648
|
+
'processed_at': datetime.utcnow()
|
|
649
|
+
})
|
|
650
|
+
except Exception:
|
|
651
|
+
# Log but don't fail - the credits were already added
|
|
652
|
+
logger.exception(f"Error marking stripe session {stripe_session_id} as processed")
|
|
653
|
+
|
|
654
|
+
# =========================================================================
|
|
655
|
+
# Admin Operations
|
|
656
|
+
# =========================================================================
|
|
657
|
+
|
|
658
|
+
def set_user_role(self, email: str, role: UserRole, admin_email: str) -> bool:
|
|
659
|
+
"""Set a user's role (admin only)."""
|
|
660
|
+
try:
|
|
661
|
+
user = self.get_user(email)
|
|
662
|
+
if not user:
|
|
663
|
+
return False
|
|
664
|
+
|
|
665
|
+
self.update_user(email, role=role.value)
|
|
666
|
+
logger.info(f"Admin {admin_email} set role for {email} to {role.value}")
|
|
667
|
+
return True
|
|
668
|
+
except Exception:
|
|
669
|
+
logger.exception("Error setting user role")
|
|
670
|
+
return False
|
|
671
|
+
|
|
672
|
+
def disable_user(self, email: str, admin_email: str) -> bool:
|
|
673
|
+
"""Disable a user account (admin only)."""
|
|
674
|
+
try:
|
|
675
|
+
self.update_user(email, is_active=False)
|
|
676
|
+
self.revoke_all_sessions(email)
|
|
677
|
+
logger.info(f"Admin {admin_email} disabled user {email}")
|
|
678
|
+
return True
|
|
679
|
+
except Exception:
|
|
680
|
+
logger.exception("Error disabling user")
|
|
681
|
+
return False
|
|
682
|
+
|
|
683
|
+
def enable_user(self, email: str, admin_email: str) -> bool:
|
|
684
|
+
"""Enable a user account (admin only)."""
|
|
685
|
+
try:
|
|
686
|
+
self.update_user(email, is_active=True)
|
|
687
|
+
logger.info(f"Admin {admin_email} enabled user {email}")
|
|
688
|
+
return True
|
|
689
|
+
except Exception:
|
|
690
|
+
logger.exception("Error enabling user")
|
|
691
|
+
return False
|
|
692
|
+
|
|
693
|
+
def increment_jobs_completed(self, email: str) -> bool:
|
|
694
|
+
"""Increment the completed jobs counter for a user using atomic increment."""
|
|
695
|
+
try:
|
|
696
|
+
# Check user exists first
|
|
697
|
+
if not self.get_user(email):
|
|
698
|
+
return False
|
|
699
|
+
|
|
700
|
+
# Use atomic increment to prevent race conditions
|
|
701
|
+
doc_ref = self.db.collection(USERS_COLLECTION).document(email.lower())
|
|
702
|
+
doc_ref.update({
|
|
703
|
+
'total_jobs_completed': Increment(1),
|
|
704
|
+
'updated_at': datetime.utcnow()
|
|
705
|
+
})
|
|
706
|
+
return True
|
|
707
|
+
except Exception:
|
|
708
|
+
logger.exception(f"Error incrementing jobs completed for {email}")
|
|
709
|
+
return False
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
# Global instance
|
|
713
|
+
_user_service = None
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def get_user_service() -> UserService:
|
|
717
|
+
"""Get the global user service instance."""
|
|
718
|
+
global _user_service
|
|
719
|
+
if _user_service is None:
|
|
720
|
+
_user_service = UserService()
|
|
721
|
+
return _user_service
|