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,792 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OAuth Credential Manager for validating and refreshing credentials.
|
|
3
|
+
|
|
4
|
+
This service provides:
|
|
5
|
+
1. Credential validation for YouTube, Dropbox, and Google Drive
|
|
6
|
+
2. Device Authorization Flow for re-authentication
|
|
7
|
+
3. Proactive monitoring with alerts when credentials expire
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from typing import Optional, Dict, Any, Tuple
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
from google.oauth2.credentials import Credentials as GoogleCredentials
|
|
18
|
+
from google.auth.transport.requests import Request as GoogleAuthRequest
|
|
19
|
+
|
|
20
|
+
from backend.config import get_settings
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CredentialStatus(str, Enum):
|
|
26
|
+
"""Status of OAuth credentials."""
|
|
27
|
+
VALID = "valid"
|
|
28
|
+
EXPIRED = "expired" # Token expired but refresh may work
|
|
29
|
+
INVALID = "invalid" # Refresh failed, re-auth needed
|
|
30
|
+
NOT_CONFIGURED = "not_configured" # No credentials stored
|
|
31
|
+
ERROR = "error" # Unknown error during validation
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CredentialCheckResult:
|
|
36
|
+
"""Result of a credential validation check."""
|
|
37
|
+
service: str
|
|
38
|
+
status: CredentialStatus
|
|
39
|
+
message: str
|
|
40
|
+
last_checked: datetime
|
|
41
|
+
expires_at: Optional[datetime] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class DeviceAuthInfo:
|
|
46
|
+
"""Information for device authorization flow."""
|
|
47
|
+
device_code: str
|
|
48
|
+
user_code: str
|
|
49
|
+
verification_url: str
|
|
50
|
+
expires_in: int
|
|
51
|
+
interval: int
|
|
52
|
+
started_at: datetime
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CredentialManager:
|
|
56
|
+
"""
|
|
57
|
+
Manages OAuth credentials for all external services.
|
|
58
|
+
|
|
59
|
+
Provides validation, refresh, and device authorization flow for:
|
|
60
|
+
- YouTube (Google OAuth)
|
|
61
|
+
- Google Drive (Google OAuth)
|
|
62
|
+
- Dropbox
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
# Secret names in Secret Manager
|
|
66
|
+
YOUTUBE_SECRET = "youtube-oauth-credentials"
|
|
67
|
+
GDRIVE_SECRET = "gdrive-oauth-credentials"
|
|
68
|
+
DROPBOX_SECRET = "dropbox-oauth-credentials"
|
|
69
|
+
|
|
70
|
+
# OAuth client credentials (for device auth flow)
|
|
71
|
+
YOUTUBE_CLIENT_SECRET = "youtube-client-credentials"
|
|
72
|
+
GDRIVE_CLIENT_SECRET = "gdrive-client-credentials"
|
|
73
|
+
|
|
74
|
+
# Google OAuth endpoints
|
|
75
|
+
GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"
|
|
76
|
+
GOOGLE_DEVICE_AUTH_URI = "https://oauth2.googleapis.com/device/code"
|
|
77
|
+
|
|
78
|
+
# Dropbox OAuth endpoints
|
|
79
|
+
DROPBOX_TOKEN_URI = "https://api.dropboxapi.com/oauth2/token"
|
|
80
|
+
|
|
81
|
+
# Scopes for each service
|
|
82
|
+
YOUTUBE_SCOPES = ["https://www.googleapis.com/auth/youtube"]
|
|
83
|
+
GDRIVE_SCOPES = ["https://www.googleapis.com/auth/drive.file"]
|
|
84
|
+
|
|
85
|
+
def __init__(self):
|
|
86
|
+
self.settings = get_settings()
|
|
87
|
+
self._pending_device_auths: Dict[str, DeviceAuthInfo] = {}
|
|
88
|
+
|
|
89
|
+
def _get_client_credentials(self, secret_name: str) -> Optional[Dict[str, str]]:
|
|
90
|
+
"""
|
|
91
|
+
Load OAuth client credentials from Secret Manager.
|
|
92
|
+
|
|
93
|
+
The secret should contain:
|
|
94
|
+
{
|
|
95
|
+
"client_id": "...",
|
|
96
|
+
"client_secret": "..."
|
|
97
|
+
}
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
creds_json = self.settings.get_secret(secret_name)
|
|
101
|
+
if not creds_json:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
creds = json.loads(creds_json)
|
|
105
|
+
if creds.get("client_id") and creds.get("client_secret"):
|
|
106
|
+
return creds
|
|
107
|
+
|
|
108
|
+
logger.warning(f"{secret_name} missing client_id or client_secret")
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
except Exception as e:
|
|
112
|
+
logger.error(f"Failed to load {secret_name}: {e}")
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def get_youtube_client_credentials(self) -> Optional[Dict[str, str]]:
|
|
116
|
+
"""Get YouTube OAuth client credentials from Secret Manager."""
|
|
117
|
+
return self._get_client_credentials(self.YOUTUBE_CLIENT_SECRET)
|
|
118
|
+
|
|
119
|
+
def get_gdrive_client_credentials(self) -> Optional[Dict[str, str]]:
|
|
120
|
+
"""Get Google Drive OAuth client credentials from Secret Manager."""
|
|
121
|
+
return self._get_client_credentials(self.GDRIVE_CLIENT_SECRET)
|
|
122
|
+
|
|
123
|
+
# =========================================================================
|
|
124
|
+
# Credential Validation
|
|
125
|
+
# =========================================================================
|
|
126
|
+
|
|
127
|
+
def check_all_credentials(self) -> Dict[str, CredentialCheckResult]:
|
|
128
|
+
"""
|
|
129
|
+
Check validity of all OAuth credentials.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Dictionary mapping service name to check result
|
|
133
|
+
"""
|
|
134
|
+
results = {}
|
|
135
|
+
|
|
136
|
+
results["youtube"] = self.check_youtube_credentials()
|
|
137
|
+
results["gdrive"] = self.check_gdrive_credentials()
|
|
138
|
+
results["dropbox"] = self.check_dropbox_credentials()
|
|
139
|
+
|
|
140
|
+
return results
|
|
141
|
+
|
|
142
|
+
def check_youtube_credentials(self) -> CredentialCheckResult:
|
|
143
|
+
"""Check if YouTube credentials are valid and can be refreshed."""
|
|
144
|
+
return self._check_google_credentials(
|
|
145
|
+
secret_name=self.YOUTUBE_SECRET,
|
|
146
|
+
service_name="youtube",
|
|
147
|
+
scopes=self.YOUTUBE_SCOPES,
|
|
148
|
+
test_api_call=self._test_youtube_api
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def check_gdrive_credentials(self) -> CredentialCheckResult:
|
|
152
|
+
"""Check if Google Drive credentials are valid and can be refreshed."""
|
|
153
|
+
return self._check_google_credentials(
|
|
154
|
+
secret_name=self.GDRIVE_SECRET,
|
|
155
|
+
service_name="gdrive",
|
|
156
|
+
scopes=self.GDRIVE_SCOPES,
|
|
157
|
+
test_api_call=self._test_gdrive_api
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def check_dropbox_credentials(self) -> CredentialCheckResult:
|
|
161
|
+
"""Check if Dropbox credentials are valid."""
|
|
162
|
+
try:
|
|
163
|
+
creds_json = self.settings.get_secret(self.DROPBOX_SECRET)
|
|
164
|
+
|
|
165
|
+
if not creds_json:
|
|
166
|
+
return CredentialCheckResult(
|
|
167
|
+
service="dropbox",
|
|
168
|
+
status=CredentialStatus.NOT_CONFIGURED,
|
|
169
|
+
message="Dropbox credentials not configured",
|
|
170
|
+
last_checked=datetime.utcnow()
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
creds = json.loads(creds_json)
|
|
174
|
+
|
|
175
|
+
if not creds.get("access_token"):
|
|
176
|
+
return CredentialCheckResult(
|
|
177
|
+
service="dropbox",
|
|
178
|
+
status=CredentialStatus.INVALID,
|
|
179
|
+
message="Dropbox credentials missing access_token",
|
|
180
|
+
last_checked=datetime.utcnow()
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Test the credentials with a simple API call
|
|
184
|
+
if self._test_dropbox_api(creds):
|
|
185
|
+
return CredentialCheckResult(
|
|
186
|
+
service="dropbox",
|
|
187
|
+
status=CredentialStatus.VALID,
|
|
188
|
+
message="Dropbox credentials are valid",
|
|
189
|
+
last_checked=datetime.utcnow()
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
# Try to refresh if we have refresh token
|
|
193
|
+
if creds.get("refresh_token") and creds.get("app_key") and creds.get("app_secret"):
|
|
194
|
+
if self._refresh_dropbox_token(creds):
|
|
195
|
+
return CredentialCheckResult(
|
|
196
|
+
service="dropbox",
|
|
197
|
+
status=CredentialStatus.VALID,
|
|
198
|
+
message="Dropbox credentials refreshed successfully",
|
|
199
|
+
last_checked=datetime.utcnow()
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return CredentialCheckResult(
|
|
203
|
+
service="dropbox",
|
|
204
|
+
status=CredentialStatus.INVALID,
|
|
205
|
+
message="Dropbox credentials invalid and refresh failed",
|
|
206
|
+
last_checked=datetime.utcnow()
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
except Exception as e:
|
|
210
|
+
logger.error(f"Error checking Dropbox credentials: {e}")
|
|
211
|
+
return CredentialCheckResult(
|
|
212
|
+
service="dropbox",
|
|
213
|
+
status=CredentialStatus.ERROR,
|
|
214
|
+
message=f"Error checking credentials: {str(e)}",
|
|
215
|
+
last_checked=datetime.utcnow()
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _check_google_credentials(
|
|
219
|
+
self,
|
|
220
|
+
secret_name: str,
|
|
221
|
+
service_name: str,
|
|
222
|
+
scopes: list,
|
|
223
|
+
test_api_call: callable
|
|
224
|
+
) -> CredentialCheckResult:
|
|
225
|
+
"""Generic Google OAuth credential check."""
|
|
226
|
+
try:
|
|
227
|
+
creds_json = self.settings.get_secret(secret_name)
|
|
228
|
+
|
|
229
|
+
if not creds_json:
|
|
230
|
+
return CredentialCheckResult(
|
|
231
|
+
service=service_name,
|
|
232
|
+
status=CredentialStatus.NOT_CONFIGURED,
|
|
233
|
+
message=f"{service_name} credentials not configured",
|
|
234
|
+
last_checked=datetime.utcnow()
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
creds_data = json.loads(creds_json)
|
|
238
|
+
|
|
239
|
+
# Check for required fields
|
|
240
|
+
required = ["refresh_token", "client_id", "client_secret"]
|
|
241
|
+
missing = [f for f in required if not creds_data.get(f)]
|
|
242
|
+
if missing:
|
|
243
|
+
return CredentialCheckResult(
|
|
244
|
+
service=service_name,
|
|
245
|
+
status=CredentialStatus.INVALID,
|
|
246
|
+
message=f"Missing required fields: {missing}",
|
|
247
|
+
last_checked=datetime.utcnow()
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Create credentials object
|
|
251
|
+
credentials = GoogleCredentials(
|
|
252
|
+
token=creds_data.get("token"),
|
|
253
|
+
refresh_token=creds_data.get("refresh_token"),
|
|
254
|
+
token_uri=creds_data.get("token_uri", self.GOOGLE_TOKEN_URI),
|
|
255
|
+
client_id=creds_data.get("client_id"),
|
|
256
|
+
client_secret=creds_data.get("client_secret"),
|
|
257
|
+
scopes=creds_data.get("scopes", scopes)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Try to refresh if expired
|
|
261
|
+
if credentials.expired or not credentials.token:
|
|
262
|
+
try:
|
|
263
|
+
credentials.refresh(GoogleAuthRequest())
|
|
264
|
+
# Update stored credentials with new token
|
|
265
|
+
self._update_google_credentials(secret_name, creds_data, credentials)
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.error(f"Failed to refresh {service_name} credentials: {e}")
|
|
268
|
+
return CredentialCheckResult(
|
|
269
|
+
service=service_name,
|
|
270
|
+
status=CredentialStatus.INVALID,
|
|
271
|
+
message=f"Token refresh failed: {str(e)}",
|
|
272
|
+
last_checked=datetime.utcnow()
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Test with API call
|
|
276
|
+
if test_api_call(credentials):
|
|
277
|
+
return CredentialCheckResult(
|
|
278
|
+
service=service_name,
|
|
279
|
+
status=CredentialStatus.VALID,
|
|
280
|
+
message=f"{service_name} credentials are valid",
|
|
281
|
+
last_checked=datetime.utcnow(),
|
|
282
|
+
expires_at=credentials.expiry
|
|
283
|
+
)
|
|
284
|
+
else:
|
|
285
|
+
return CredentialCheckResult(
|
|
286
|
+
service=service_name,
|
|
287
|
+
status=CredentialStatus.INVALID,
|
|
288
|
+
message=f"{service_name} API test failed",
|
|
289
|
+
last_checked=datetime.utcnow()
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
except json.JSONDecodeError as e:
|
|
293
|
+
return CredentialCheckResult(
|
|
294
|
+
service=service_name,
|
|
295
|
+
status=CredentialStatus.INVALID,
|
|
296
|
+
message=f"Invalid JSON in credentials: {str(e)}",
|
|
297
|
+
last_checked=datetime.utcnow()
|
|
298
|
+
)
|
|
299
|
+
except Exception as e:
|
|
300
|
+
logger.error(f"Error checking {service_name} credentials: {e}")
|
|
301
|
+
return CredentialCheckResult(
|
|
302
|
+
service=service_name,
|
|
303
|
+
status=CredentialStatus.ERROR,
|
|
304
|
+
message=f"Error: {str(e)}",
|
|
305
|
+
last_checked=datetime.utcnow()
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _update_google_credentials(
|
|
309
|
+
self,
|
|
310
|
+
secret_name: str,
|
|
311
|
+
original_data: dict,
|
|
312
|
+
credentials: GoogleCredentials
|
|
313
|
+
) -> bool:
|
|
314
|
+
"""Update stored Google credentials with refreshed token."""
|
|
315
|
+
try:
|
|
316
|
+
from google.cloud import secretmanager
|
|
317
|
+
|
|
318
|
+
updated_data = original_data.copy()
|
|
319
|
+
updated_data["token"] = credentials.token
|
|
320
|
+
if credentials.expiry:
|
|
321
|
+
updated_data["expiry"] = credentials.expiry.isoformat()
|
|
322
|
+
|
|
323
|
+
# Add new secret version
|
|
324
|
+
client = secretmanager.SecretManagerServiceClient()
|
|
325
|
+
project_id = self.settings.google_cloud_project
|
|
326
|
+
secret_path = f"projects/{project_id}/secrets/{secret_name}"
|
|
327
|
+
|
|
328
|
+
client.add_secret_version(
|
|
329
|
+
parent=secret_path,
|
|
330
|
+
payload={"data": json.dumps(updated_data).encode("utf-8")}
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
logger.info(f"Updated {secret_name} with refreshed token")
|
|
334
|
+
return True
|
|
335
|
+
|
|
336
|
+
except Exception as e:
|
|
337
|
+
logger.error(f"Failed to update {secret_name}: {e}")
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
# =========================================================================
|
|
341
|
+
# API Test Methods
|
|
342
|
+
# =========================================================================
|
|
343
|
+
|
|
344
|
+
def _test_youtube_api(self, credentials: GoogleCredentials) -> bool:
|
|
345
|
+
"""Test YouTube credentials with a simple API call."""
|
|
346
|
+
try:
|
|
347
|
+
from googleapiclient.discovery import build
|
|
348
|
+
|
|
349
|
+
youtube = build("youtube", "v3", credentials=credentials)
|
|
350
|
+
# Just get channel info - minimal API call
|
|
351
|
+
request = youtube.channels().list(part="id", mine=True)
|
|
352
|
+
response = request.execute()
|
|
353
|
+
|
|
354
|
+
return "items" in response
|
|
355
|
+
|
|
356
|
+
except Exception as e:
|
|
357
|
+
logger.error(f"YouTube API test failed: {e}")
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
def _test_gdrive_api(self, credentials: GoogleCredentials) -> bool:
|
|
361
|
+
"""Test Google Drive credentials with a simple API call."""
|
|
362
|
+
try:
|
|
363
|
+
from googleapiclient.discovery import build
|
|
364
|
+
|
|
365
|
+
drive = build("drive", "v3", credentials=credentials)
|
|
366
|
+
# Just get about info - minimal API call
|
|
367
|
+
request = drive.about().get(fields="user")
|
|
368
|
+
response = request.execute()
|
|
369
|
+
|
|
370
|
+
return "user" in response
|
|
371
|
+
|
|
372
|
+
except Exception as e:
|
|
373
|
+
logger.error(f"Google Drive API test failed: {e}")
|
|
374
|
+
return False
|
|
375
|
+
|
|
376
|
+
def _test_dropbox_api(self, creds: dict) -> bool:
|
|
377
|
+
"""Test Dropbox credentials with a simple API call."""
|
|
378
|
+
try:
|
|
379
|
+
import dropbox
|
|
380
|
+
|
|
381
|
+
dbx = dropbox.Dropbox(creds["access_token"])
|
|
382
|
+
# Just get account info - minimal API call
|
|
383
|
+
dbx.users_get_current_account()
|
|
384
|
+
return True
|
|
385
|
+
|
|
386
|
+
except Exception as e:
|
|
387
|
+
logger.error(f"Dropbox API test failed: {e}")
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
def _refresh_dropbox_token(self, creds: dict) -> bool:
|
|
391
|
+
"""Try to refresh Dropbox token."""
|
|
392
|
+
try:
|
|
393
|
+
import requests
|
|
394
|
+
|
|
395
|
+
response = requests.post(
|
|
396
|
+
self.DROPBOX_TOKEN_URI,
|
|
397
|
+
data={
|
|
398
|
+
"grant_type": "refresh_token",
|
|
399
|
+
"refresh_token": creds["refresh_token"],
|
|
400
|
+
"client_id": creds["app_key"],
|
|
401
|
+
"client_secret": creds["app_secret"],
|
|
402
|
+
},
|
|
403
|
+
timeout=30
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
if response.status_code == 200:
|
|
407
|
+
token_data = response.json()
|
|
408
|
+
creds["access_token"] = token_data["access_token"]
|
|
409
|
+
|
|
410
|
+
# Update in Secret Manager
|
|
411
|
+
self._update_dropbox_credentials(creds)
|
|
412
|
+
return True
|
|
413
|
+
|
|
414
|
+
return False
|
|
415
|
+
|
|
416
|
+
except Exception as e:
|
|
417
|
+
logger.error(f"Dropbox token refresh failed: {e}")
|
|
418
|
+
return False
|
|
419
|
+
|
|
420
|
+
def _update_dropbox_credentials(self, creds: dict) -> bool:
|
|
421
|
+
"""Update stored Dropbox credentials."""
|
|
422
|
+
try:
|
|
423
|
+
from google.cloud import secretmanager
|
|
424
|
+
|
|
425
|
+
client = secretmanager.SecretManagerServiceClient()
|
|
426
|
+
project_id = self.settings.google_cloud_project
|
|
427
|
+
secret_path = f"projects/{project_id}/secrets/{self.DROPBOX_SECRET}"
|
|
428
|
+
|
|
429
|
+
client.add_secret_version(
|
|
430
|
+
parent=secret_path,
|
|
431
|
+
payload={"data": json.dumps(creds).encode("utf-8")}
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
logger.info("Updated Dropbox credentials with refreshed token")
|
|
435
|
+
return True
|
|
436
|
+
|
|
437
|
+
except Exception as e:
|
|
438
|
+
logger.error(f"Failed to update Dropbox credentials: {e}")
|
|
439
|
+
return False
|
|
440
|
+
|
|
441
|
+
# =========================================================================
|
|
442
|
+
# Device Authorization Flow
|
|
443
|
+
# =========================================================================
|
|
444
|
+
|
|
445
|
+
def start_youtube_device_auth(
|
|
446
|
+
self,
|
|
447
|
+
client_id: Optional[str] = None,
|
|
448
|
+
client_secret: Optional[str] = None
|
|
449
|
+
) -> DeviceAuthInfo:
|
|
450
|
+
"""
|
|
451
|
+
Start YouTube device authorization flow.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
client_id: Google OAuth client ID (optional, reads from Secret Manager if not provided)
|
|
455
|
+
client_secret: Google OAuth client secret (optional, reads from Secret Manager if not provided)
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
DeviceAuthInfo with user code and verification URL
|
|
459
|
+
"""
|
|
460
|
+
# Load from Secret Manager if not provided
|
|
461
|
+
if not client_id or not client_secret:
|
|
462
|
+
stored_creds = self.get_youtube_client_credentials()
|
|
463
|
+
if not stored_creds:
|
|
464
|
+
raise Exception(
|
|
465
|
+
"YouTube client credentials not found. Either pass client_id/client_secret "
|
|
466
|
+
"or create the 'youtube-client-credentials' secret in Secret Manager."
|
|
467
|
+
)
|
|
468
|
+
client_id = stored_creds["client_id"]
|
|
469
|
+
client_secret = stored_creds["client_secret"]
|
|
470
|
+
|
|
471
|
+
return self._start_google_device_auth(
|
|
472
|
+
client_id=client_id,
|
|
473
|
+
client_secret=client_secret,
|
|
474
|
+
scopes=self.YOUTUBE_SCOPES,
|
|
475
|
+
service_name="youtube"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def start_gdrive_device_auth(
|
|
479
|
+
self,
|
|
480
|
+
client_id: Optional[str] = None,
|
|
481
|
+
client_secret: Optional[str] = None
|
|
482
|
+
) -> DeviceAuthInfo:
|
|
483
|
+
"""
|
|
484
|
+
Start Google Drive device authorization flow.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
client_id: Google OAuth client ID (optional, reads from Secret Manager if not provided)
|
|
488
|
+
client_secret: Google OAuth client secret (optional, reads from Secret Manager if not provided)
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
DeviceAuthInfo with user code and verification URL
|
|
492
|
+
"""
|
|
493
|
+
# Load from Secret Manager if not provided
|
|
494
|
+
if not client_id or not client_secret:
|
|
495
|
+
stored_creds = self.get_gdrive_client_credentials()
|
|
496
|
+
if not stored_creds:
|
|
497
|
+
raise Exception(
|
|
498
|
+
"Google Drive client credentials not found. Either pass client_id/client_secret "
|
|
499
|
+
"or create the 'gdrive-client-credentials' secret in Secret Manager."
|
|
500
|
+
)
|
|
501
|
+
client_id = stored_creds["client_id"]
|
|
502
|
+
client_secret = stored_creds["client_secret"]
|
|
503
|
+
|
|
504
|
+
return self._start_google_device_auth(
|
|
505
|
+
client_id=client_id,
|
|
506
|
+
client_secret=client_secret,
|
|
507
|
+
scopes=self.GDRIVE_SCOPES,
|
|
508
|
+
service_name="gdrive"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def _start_google_device_auth(
|
|
512
|
+
self,
|
|
513
|
+
client_id: str,
|
|
514
|
+
client_secret: str,
|
|
515
|
+
scopes: list,
|
|
516
|
+
service_name: str
|
|
517
|
+
) -> DeviceAuthInfo:
|
|
518
|
+
"""Start Google device authorization flow."""
|
|
519
|
+
import requests
|
|
520
|
+
|
|
521
|
+
logger.info(f"[{service_name}] Starting device auth flow with scopes: {scopes}")
|
|
522
|
+
|
|
523
|
+
response = requests.post(
|
|
524
|
+
self.GOOGLE_DEVICE_AUTH_URI,
|
|
525
|
+
data={
|
|
526
|
+
"client_id": client_id,
|
|
527
|
+
"scope": " ".join(scopes),
|
|
528
|
+
},
|
|
529
|
+
timeout=30
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
if response.status_code != 200:
|
|
533
|
+
logger.error(f"[{service_name}] Device auth request failed: {response.status_code} - {response.text}")
|
|
534
|
+
raise Exception(f"Device auth request failed: {response.text}")
|
|
535
|
+
|
|
536
|
+
logger.info(f"[{service_name}] Device auth initiated successfully")
|
|
537
|
+
|
|
538
|
+
data = response.json()
|
|
539
|
+
|
|
540
|
+
# Google uses 'verification_uri' but some docs show 'verification_url'
|
|
541
|
+
verification_url = data.get("verification_uri") or data.get("verification_url")
|
|
542
|
+
|
|
543
|
+
device_info = DeviceAuthInfo(
|
|
544
|
+
device_code=data["device_code"],
|
|
545
|
+
user_code=data["user_code"],
|
|
546
|
+
verification_url=verification_url,
|
|
547
|
+
expires_in=data["expires_in"],
|
|
548
|
+
interval=data.get("interval", 5),
|
|
549
|
+
started_at=datetime.utcnow()
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
# Store for polling, include client secret for token exchange
|
|
553
|
+
self._pending_device_auths[f"{service_name}:{data['device_code']}"] = {
|
|
554
|
+
"info": device_info,
|
|
555
|
+
"client_id": client_id,
|
|
556
|
+
"client_secret": client_secret,
|
|
557
|
+
"scopes": scopes,
|
|
558
|
+
"service_name": service_name
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return device_info
|
|
562
|
+
|
|
563
|
+
def poll_device_auth(self, service_name: str, device_code: str) -> Tuple[str, Optional[dict]]:
|
|
564
|
+
"""
|
|
565
|
+
Poll for device authorization completion.
|
|
566
|
+
|
|
567
|
+
This method is STATELESS - it fetches client credentials from Secret Manager
|
|
568
|
+
and polls Google directly. This works correctly in serverless environments
|
|
569
|
+
like Cloud Run where in-memory state is not preserved between requests.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
service_name: "youtube" or "gdrive"
|
|
573
|
+
device_code: The device code from start_*_device_auth
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
Tuple of (status, token_data)
|
|
577
|
+
status: "pending", "complete", "expired", "error"
|
|
578
|
+
token_data: Token data if complete, None otherwise
|
|
579
|
+
"""
|
|
580
|
+
import requests
|
|
581
|
+
|
|
582
|
+
logger.info(f"[{service_name}] Polling device auth for code: {device_code[:20]}...")
|
|
583
|
+
|
|
584
|
+
# Get client credentials from Secret Manager (stateless approach)
|
|
585
|
+
if service_name == "youtube":
|
|
586
|
+
client_creds = self.get_youtube_client_credentials()
|
|
587
|
+
scopes = self.YOUTUBE_SCOPES
|
|
588
|
+
secret_name = self.YOUTUBE_SECRET
|
|
589
|
+
elif service_name == "gdrive":
|
|
590
|
+
client_creds = self.get_gdrive_client_credentials()
|
|
591
|
+
scopes = self.GDRIVE_SCOPES
|
|
592
|
+
secret_name = self.GDRIVE_SECRET
|
|
593
|
+
else:
|
|
594
|
+
logger.error(f"[{service_name}] Unknown service")
|
|
595
|
+
return ("error", {"message": f"Unknown service: {service_name}"})
|
|
596
|
+
|
|
597
|
+
if not client_creds:
|
|
598
|
+
logger.error(f"[{service_name}] Client credentials not found in Secret Manager")
|
|
599
|
+
return ("error", {"message": f"Client credentials not found in Secret Manager for {service_name}"})
|
|
600
|
+
|
|
601
|
+
client_id = client_creds.get("client_id")
|
|
602
|
+
client_secret = client_creds.get("client_secret")
|
|
603
|
+
|
|
604
|
+
if not client_id or not client_secret:
|
|
605
|
+
logger.error(f"[{service_name}] Invalid client credentials (missing client_id or client_secret)")
|
|
606
|
+
return ("error", {"message": f"Invalid client credentials for {service_name}"})
|
|
607
|
+
|
|
608
|
+
logger.info(f"[{service_name}] Got client credentials, polling Google token endpoint...")
|
|
609
|
+
|
|
610
|
+
# Poll Google token endpoint
|
|
611
|
+
response = requests.post(
|
|
612
|
+
self.GOOGLE_TOKEN_URI,
|
|
613
|
+
data={
|
|
614
|
+
"client_id": client_id,
|
|
615
|
+
"client_secret": client_secret,
|
|
616
|
+
"device_code": device_code,
|
|
617
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
618
|
+
},
|
|
619
|
+
timeout=30
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
data = response.json()
|
|
623
|
+
logger.info(f"[{service_name}] Token endpoint response: status={response.status_code}")
|
|
624
|
+
|
|
625
|
+
if response.status_code == 200:
|
|
626
|
+
logger.info(f"[{service_name}] Token exchange successful! Got access token and refresh_token={bool(data.get('refresh_token'))}")
|
|
627
|
+
|
|
628
|
+
# Success! Store the credentials
|
|
629
|
+
token_data = {
|
|
630
|
+
"token": data["access_token"],
|
|
631
|
+
"refresh_token": data.get("refresh_token"),
|
|
632
|
+
"token_uri": self.GOOGLE_TOKEN_URI,
|
|
633
|
+
"client_id": client_id,
|
|
634
|
+
"client_secret": client_secret,
|
|
635
|
+
"scopes": scopes,
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
# Save to Secret Manager
|
|
639
|
+
logger.info(f"[{service_name}] Saving credentials to secret: {secret_name}")
|
|
640
|
+
saved = self._save_credentials_to_secret(secret_name, token_data)
|
|
641
|
+
if not saved:
|
|
642
|
+
logger.error(f"[{service_name}] Failed to save credentials to Secret Manager!")
|
|
643
|
+
return ("error", {"message": "Token exchange succeeded but failed to save credentials to Secret Manager"})
|
|
644
|
+
|
|
645
|
+
logger.info(f"[{service_name}] Device auth flow COMPLETE - credentials saved successfully")
|
|
646
|
+
return ("complete", token_data)
|
|
647
|
+
|
|
648
|
+
elif "error" in data:
|
|
649
|
+
error = data["error"]
|
|
650
|
+
logger.info(f"[{service_name}] Token endpoint returned error: {error}")
|
|
651
|
+
|
|
652
|
+
if error == "authorization_pending":
|
|
653
|
+
return ("pending", {"message": "Waiting for user to authorize. Please visit the verification URL and enter the code."})
|
|
654
|
+
elif error == "slow_down":
|
|
655
|
+
return ("pending", {"message": "Please wait a few more seconds before polling again."})
|
|
656
|
+
elif error == "expired_token":
|
|
657
|
+
logger.warning(f"[{service_name}] Device code expired")
|
|
658
|
+
return ("expired", {"message": "Device code expired. Please start a new device auth flow."})
|
|
659
|
+
elif error == "access_denied":
|
|
660
|
+
logger.warning(f"[{service_name}] User denied access")
|
|
661
|
+
return ("error", {"message": "User denied access"})
|
|
662
|
+
else:
|
|
663
|
+
logger.error(f"[{service_name}] Unexpected error: {data}")
|
|
664
|
+
return ("error", {"message": data.get("error_description", error)})
|
|
665
|
+
|
|
666
|
+
logger.error(f"[{service_name}] Unknown response from token endpoint: {data}")
|
|
667
|
+
return ("error", {"message": "Unknown response from token endpoint"})
|
|
668
|
+
|
|
669
|
+
def _save_credentials_to_secret(self, secret_name: str, token_data: dict) -> bool:
|
|
670
|
+
"""Save credentials to Secret Manager, creating the secret if needed."""
|
|
671
|
+
logger.info(f"[SecretManager] Attempting to save credentials to: {secret_name}")
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
from google.cloud import secretmanager
|
|
675
|
+
from google.api_core import exceptions as gcp_exceptions
|
|
676
|
+
|
|
677
|
+
client = secretmanager.SecretManagerServiceClient()
|
|
678
|
+
project_id = self.settings.google_cloud_project
|
|
679
|
+
|
|
680
|
+
if not project_id:
|
|
681
|
+
logger.error(f"[SecretManager] No GCP project configured!")
|
|
682
|
+
return False
|
|
683
|
+
|
|
684
|
+
parent = f"projects/{project_id}"
|
|
685
|
+
secret_path = f"{parent}/secrets/{secret_name}"
|
|
686
|
+
|
|
687
|
+
logger.info(f"[SecretManager] Using project: {project_id}, secret path: {secret_path}")
|
|
688
|
+
|
|
689
|
+
# Try to add a version to existing secret
|
|
690
|
+
try:
|
|
691
|
+
client.add_secret_version(
|
|
692
|
+
parent=secret_path,
|
|
693
|
+
payload={"data": json.dumps(token_data).encode("utf-8")}
|
|
694
|
+
)
|
|
695
|
+
logger.info(f"[SecretManager] SUCCESS - Added new version to existing secret: {secret_name}")
|
|
696
|
+
return True
|
|
697
|
+
|
|
698
|
+
except gcp_exceptions.NotFound:
|
|
699
|
+
# Secret doesn't exist, create it first
|
|
700
|
+
logger.info(f"[SecretManager] Secret {secret_name} not found, creating new secret...")
|
|
701
|
+
client.create_secret(
|
|
702
|
+
parent=parent,
|
|
703
|
+
secret_id=secret_name,
|
|
704
|
+
secret={"replication": {"automatic": {}}}
|
|
705
|
+
)
|
|
706
|
+
logger.info(f"[SecretManager] Created secret: {secret_name}")
|
|
707
|
+
|
|
708
|
+
# Now add the version
|
|
709
|
+
client.add_secret_version(
|
|
710
|
+
parent=secret_path,
|
|
711
|
+
payload={"data": json.dumps(token_data).encode("utf-8")}
|
|
712
|
+
)
|
|
713
|
+
logger.info(f"[SecretManager] SUCCESS - Created secret and saved credentials to: {secret_name}")
|
|
714
|
+
return True
|
|
715
|
+
|
|
716
|
+
except Exception as e:
|
|
717
|
+
logger.error(f"[SecretManager] FAILED to save credentials to {secret_name}: {type(e).__name__}: {e}")
|
|
718
|
+
import traceback
|
|
719
|
+
logger.error(f"[SecretManager] Traceback: {traceback.format_exc()}")
|
|
720
|
+
return False
|
|
721
|
+
|
|
722
|
+
# =========================================================================
|
|
723
|
+
# Alerts
|
|
724
|
+
# =========================================================================
|
|
725
|
+
|
|
726
|
+
def send_credential_alert(
|
|
727
|
+
self,
|
|
728
|
+
invalid_services: list[CredentialCheckResult],
|
|
729
|
+
discord_webhook_url: Optional[str] = None
|
|
730
|
+
) -> bool:
|
|
731
|
+
"""
|
|
732
|
+
Send alert about invalid credentials.
|
|
733
|
+
|
|
734
|
+
Args:
|
|
735
|
+
invalid_services: List of services with invalid credentials
|
|
736
|
+
discord_webhook_url: Discord webhook URL for notifications
|
|
737
|
+
|
|
738
|
+
Returns:
|
|
739
|
+
True if alert was sent successfully
|
|
740
|
+
"""
|
|
741
|
+
if not discord_webhook_url:
|
|
742
|
+
logger.warning("No Discord webhook URL configured for alerts")
|
|
743
|
+
return False
|
|
744
|
+
|
|
745
|
+
try:
|
|
746
|
+
import requests
|
|
747
|
+
|
|
748
|
+
services_list = "\n".join([
|
|
749
|
+
f"• **{r.service}**: {r.message}"
|
|
750
|
+
for r in invalid_services
|
|
751
|
+
])
|
|
752
|
+
|
|
753
|
+
# Get the API base URL for re-auth links
|
|
754
|
+
api_url = self.settings.api_base_url if hasattr(self.settings, 'api_base_url') else "https://your-api-url"
|
|
755
|
+
|
|
756
|
+
message = {
|
|
757
|
+
"embeds": [{
|
|
758
|
+
"title": "⚠️ OAuth Credentials Need Attention",
|
|
759
|
+
"description": f"The following service credentials need re-authorization:\n\n{services_list}",
|
|
760
|
+
"color": 16744256, # Orange
|
|
761
|
+
"fields": [
|
|
762
|
+
{
|
|
763
|
+
"name": "Re-authorize",
|
|
764
|
+
"value": f"Visit `{api_url}/api/auth/status` to start re-authorization flow",
|
|
765
|
+
"inline": False
|
|
766
|
+
}
|
|
767
|
+
],
|
|
768
|
+
"footer": {
|
|
769
|
+
"text": "karaoke-gen backend"
|
|
770
|
+
},
|
|
771
|
+
"timestamp": datetime.utcnow().isoformat()
|
|
772
|
+
}]
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
response = requests.post(discord_webhook_url, json=message, timeout=30)
|
|
776
|
+
return response.status_code in (200, 204)
|
|
777
|
+
|
|
778
|
+
except Exception as e:
|
|
779
|
+
logger.error(f"Failed to send credential alert: {e}")
|
|
780
|
+
return False
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
# Singleton instance
|
|
784
|
+
_credential_manager: Optional[CredentialManager] = None
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def get_credential_manager() -> CredentialManager:
|
|
788
|
+
"""Get the singleton credential manager instance."""
|
|
789
|
+
global _credential_manager
|
|
790
|
+
if _credential_manager is None:
|
|
791
|
+
_credential_manager = CredentialManager()
|
|
792
|
+
return _credential_manager
|