karaoke-gen 0.86.7__py3-none-any.whl → 0.96.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +742 -0
- backend/api/routes/audio_search.py +903 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2076 -0
- backend/api/routes/health.py +344 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1610 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1014 -0
- backend/config.py +172 -0
- backend/main.py +133 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +405 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +842 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +275 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +88 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +339 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +273 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +525 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/style_loader.py +3 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI dependencies for authentication and authorization.
|
|
3
|
+
"""
|
|
4
|
+
import logging
|
|
5
|
+
import secrets
|
|
6
|
+
from datetime import datetime, timedelta, UTC
|
|
7
|
+
from typing import Optional, Tuple, Callable, Union
|
|
8
|
+
from fastapi import Depends, HTTPException, Header, Query, Request, Path
|
|
9
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
10
|
+
|
|
11
|
+
from backend.services.auth_service import get_auth_service, UserType, AuthService, AuthResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def generate_review_token() -> str:
|
|
18
|
+
"""Generate a cryptographically secure review token."""
|
|
19
|
+
return secrets.token_urlsafe(32)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_review_token_expiry(hours: int = 24) -> datetime:
|
|
23
|
+
"""Get expiry time for a review token."""
|
|
24
|
+
return datetime.now(UTC) + timedelta(hours=hours)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# HTTP Bearer security scheme (Authorization: Bearer <token>)
|
|
28
|
+
security = HTTPBearer(auto_error=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def get_token_from_request(
|
|
32
|
+
request: Request,
|
|
33
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
34
|
+
token: Optional[str] = Query(None, description="Access token (alternative to Bearer header)")
|
|
35
|
+
) -> Optional[str]:
|
|
36
|
+
"""
|
|
37
|
+
Extract access token from request.
|
|
38
|
+
|
|
39
|
+
Supports three methods (in priority order):
|
|
40
|
+
1. X-Admin-Token header: Used by Cloud Tasks (since OIDC overwrites Authorization)
|
|
41
|
+
2. Authorization header: Authorization: Bearer <token>
|
|
42
|
+
3. Query parameter: ?token=<token> (for download links)
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Token string or None
|
|
46
|
+
"""
|
|
47
|
+
# Check X-Admin-Token header first (used by Cloud Tasks)
|
|
48
|
+
# Cloud Tasks OIDC token overwrites Authorization header, so we use a custom header
|
|
49
|
+
admin_token = request.headers.get("X-Admin-Token")
|
|
50
|
+
if admin_token:
|
|
51
|
+
return admin_token
|
|
52
|
+
|
|
53
|
+
# Try Authorization header
|
|
54
|
+
if credentials:
|
|
55
|
+
return credentials.credentials
|
|
56
|
+
|
|
57
|
+
# Try query parameter
|
|
58
|
+
if token:
|
|
59
|
+
return token
|
|
60
|
+
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def require_auth(
|
|
65
|
+
request: Request,
|
|
66
|
+
auth_service: AuthService = Depends(get_auth_service),
|
|
67
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
68
|
+
token: Optional[str] = Query(None)
|
|
69
|
+
) -> AuthResult:
|
|
70
|
+
"""
|
|
71
|
+
Require authentication for an endpoint.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
AuthResult with full authentication context including:
|
|
75
|
+
- is_valid, user_type, remaining_uses, message (backward compatible via tuple unpacking)
|
|
76
|
+
- user_email: Email of authenticated user (if session/API key auth)
|
|
77
|
+
- is_admin: Whether user has admin privileges
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
HTTPException: 401 if authentication fails
|
|
81
|
+
"""
|
|
82
|
+
# Get token from request
|
|
83
|
+
token_str = await get_token_from_request(request, credentials, token)
|
|
84
|
+
|
|
85
|
+
if not token_str:
|
|
86
|
+
raise HTTPException(
|
|
87
|
+
status_code=401,
|
|
88
|
+
detail="Authentication required. Provide token via Authorization header or ?token= parameter"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Validate token using the full method
|
|
92
|
+
auth_result = auth_service.validate_token_full(token_str)
|
|
93
|
+
|
|
94
|
+
# Get request_id from middleware for correlation
|
|
95
|
+
request_id = getattr(request.state, "request_id", None)
|
|
96
|
+
|
|
97
|
+
if not auth_result.is_valid:
|
|
98
|
+
# Log auth failure with request_id for correlation
|
|
99
|
+
auth_header = request.headers.get("Authorization", "")
|
|
100
|
+
logger.warning(
|
|
101
|
+
"auth_failed",
|
|
102
|
+
extra={
|
|
103
|
+
"request_id": request_id,
|
|
104
|
+
"audit_type": "auth_event",
|
|
105
|
+
"auth_message": auth_result.message,
|
|
106
|
+
"token_provided": bool(token_str),
|
|
107
|
+
"token_length": len(token_str) if token_str else 0,
|
|
108
|
+
"auth_header_present": bool(auth_header),
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
raise HTTPException(
|
|
112
|
+
status_code=401,
|
|
113
|
+
detail=f"Authentication failed: {auth_result.message}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Log successful auth with request_id for correlation with request audit
|
|
117
|
+
logger.info(
|
|
118
|
+
"auth_success",
|
|
119
|
+
extra={
|
|
120
|
+
"request_id": request_id,
|
|
121
|
+
"user_email": auth_result.user_email,
|
|
122
|
+
"user_type": auth_result.user_type.value if auth_result.user_type else None,
|
|
123
|
+
"is_admin": auth_result.is_admin,
|
|
124
|
+
"remaining_uses": auth_result.remaining_uses,
|
|
125
|
+
"audit_type": "auth_event",
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return auth_result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def require_auth_legacy(
|
|
133
|
+
request: Request,
|
|
134
|
+
auth_service: AuthService = Depends(get_auth_service),
|
|
135
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
136
|
+
token: Optional[str] = Query(None)
|
|
137
|
+
) -> Tuple[str, UserType, int]:
|
|
138
|
+
"""
|
|
139
|
+
Legacy authentication dependency that returns tuple.
|
|
140
|
+
|
|
141
|
+
DEPRECATED: Use require_auth which returns AuthResult instead.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
(token, user_type, remaining_uses)
|
|
145
|
+
"""
|
|
146
|
+
token_str = await get_token_from_request(request, credentials, token)
|
|
147
|
+
|
|
148
|
+
if not token_str:
|
|
149
|
+
raise HTTPException(
|
|
150
|
+
status_code=401,
|
|
151
|
+
detail="Authentication required. Provide token via Authorization header or ?token= parameter"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
is_valid, user_type, remaining_uses, message = auth_service.validate_token(token_str)
|
|
155
|
+
|
|
156
|
+
if not is_valid:
|
|
157
|
+
raise HTTPException(
|
|
158
|
+
status_code=401,
|
|
159
|
+
detail=f"Authentication failed: {message}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return token_str, user_type, remaining_uses
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def require_admin(
|
|
166
|
+
auth_result: AuthResult = Depends(require_auth)
|
|
167
|
+
) -> AuthResult:
|
|
168
|
+
"""
|
|
169
|
+
Require admin access for an endpoint.
|
|
170
|
+
|
|
171
|
+
Admin access is granted if:
|
|
172
|
+
- Using an admin token from ADMIN_TOKENS env var
|
|
173
|
+
- User email is from admin domain (e.g., @nomadkaraoke.com)
|
|
174
|
+
- User role is set to ADMIN in database
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
AuthResult with admin privileges
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
HTTPException: 403 if user is not admin
|
|
181
|
+
"""
|
|
182
|
+
if not auth_result.is_admin:
|
|
183
|
+
logger.warning(
|
|
184
|
+
f"Admin access denied for {auth_result.user_type} user "
|
|
185
|
+
f"(email: {auth_result.user_email or 'unknown'})"
|
|
186
|
+
)
|
|
187
|
+
raise HTTPException(
|
|
188
|
+
status_code=403,
|
|
189
|
+
detail="Admin access required"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return auth_result
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def optional_auth(
|
|
196
|
+
auth_service: AuthService = Depends(get_auth_service),
|
|
197
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
198
|
+
token: Optional[str] = Query(None)
|
|
199
|
+
) -> Optional[Tuple[str, UserType, int]]:
|
|
200
|
+
"""
|
|
201
|
+
Optional authentication - doesn't fail if no token provided.
|
|
202
|
+
|
|
203
|
+
Useful for endpoints that have different behavior for authenticated users.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
(token, user_type, remaining_uses) if authenticated, None otherwise
|
|
207
|
+
"""
|
|
208
|
+
# Get token
|
|
209
|
+
token_str = None
|
|
210
|
+
if credentials:
|
|
211
|
+
token_str = credentials.credentials
|
|
212
|
+
elif token:
|
|
213
|
+
token_str = token
|
|
214
|
+
|
|
215
|
+
if not token_str:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
# Validate token
|
|
219
|
+
is_valid, user_type, remaining_uses, message = auth_service.validate_token(token_str)
|
|
220
|
+
|
|
221
|
+
if not is_valid:
|
|
222
|
+
logger.debug(f"Optional auth failed: {message}")
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
return token_str, user_type, remaining_uses
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def require_review_auth_factory(job_id_param: str = "job_id"):
|
|
229
|
+
"""
|
|
230
|
+
Factory to create a review authentication dependency.
|
|
231
|
+
|
|
232
|
+
Accepts either:
|
|
233
|
+
1. Full user authentication (admin/user token) - also validates job ownership
|
|
234
|
+
2. Job-specific review token (only valid for the specific job)
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
job_id_param: Name of the path parameter containing the job ID
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Dependency function that validates review access
|
|
241
|
+
"""
|
|
242
|
+
async def require_review_auth(
|
|
243
|
+
request: Request,
|
|
244
|
+
job_id: str = Path(...),
|
|
245
|
+
review_token: Optional[str] = Query(None, description="Job-specific review token"),
|
|
246
|
+
auth_service: AuthService = Depends(get_auth_service),
|
|
247
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
248
|
+
token: Optional[str] = Query(None, alias="token", description="Full access token")
|
|
249
|
+
) -> Tuple[str, str]:
|
|
250
|
+
"""
|
|
251
|
+
Validate review access for a job.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
(job_id, auth_type) where auth_type is "full" or "review_token"
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
HTTPException: 401 if authentication fails
|
|
258
|
+
HTTPException: 403 if user doesn't own the job (for full auth)
|
|
259
|
+
"""
|
|
260
|
+
# Import here to avoid circular dependency
|
|
261
|
+
from backend.services.job_manager import JobManager
|
|
262
|
+
|
|
263
|
+
job_manager = JobManager()
|
|
264
|
+
|
|
265
|
+
# Try full authentication first
|
|
266
|
+
full_token = None
|
|
267
|
+
if credentials:
|
|
268
|
+
full_token = credentials.credentials
|
|
269
|
+
elif token:
|
|
270
|
+
full_token = token
|
|
271
|
+
|
|
272
|
+
if full_token:
|
|
273
|
+
auth_result = auth_service.validate_token_full(full_token)
|
|
274
|
+
if auth_result.is_valid:
|
|
275
|
+
# For full auth, also verify job ownership
|
|
276
|
+
job = job_manager.get_job(job_id)
|
|
277
|
+
if not job:
|
|
278
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
279
|
+
|
|
280
|
+
# Check ownership: admin can access all, users only their own jobs
|
|
281
|
+
if not auth_result.is_admin:
|
|
282
|
+
if auth_result.user_email and job.user_email:
|
|
283
|
+
if auth_result.user_email.lower() != job.user_email.lower():
|
|
284
|
+
logger.warning(
|
|
285
|
+
f"Review access denied: user {auth_result.user_email} "
|
|
286
|
+
f"tried to access job {job_id} owned by {job.user_email}"
|
|
287
|
+
)
|
|
288
|
+
raise HTTPException(
|
|
289
|
+
status_code=403,
|
|
290
|
+
detail="You don't have permission to access this job's review"
|
|
291
|
+
)
|
|
292
|
+
elif job.user_email:
|
|
293
|
+
# Token auth without email trying to access a job with owner
|
|
294
|
+
raise HTTPException(
|
|
295
|
+
status_code=403,
|
|
296
|
+
detail="You don't have permission to access this job's review"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
logger.info(f"Review access granted via full auth ({auth_result.user_type}) for job {job_id}")
|
|
300
|
+
return job_id, "full"
|
|
301
|
+
|
|
302
|
+
# Try review token
|
|
303
|
+
if review_token:
|
|
304
|
+
job = job_manager.get_job(job_id)
|
|
305
|
+
|
|
306
|
+
if not job:
|
|
307
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
308
|
+
|
|
309
|
+
# Validate review token matches
|
|
310
|
+
if job.review_token and secrets.compare_digest(job.review_token, review_token):
|
|
311
|
+
# Check expiry if set
|
|
312
|
+
if job.review_token_expires_at:
|
|
313
|
+
now = datetime.now(UTC)
|
|
314
|
+
# Handle timezone-naive datetimes from Firestore
|
|
315
|
+
expiry = job.review_token_expires_at
|
|
316
|
+
if expiry.tzinfo is None:
|
|
317
|
+
expiry = expiry.replace(tzinfo=UTC)
|
|
318
|
+
|
|
319
|
+
if now > expiry:
|
|
320
|
+
logger.warning(f"Review token expired for job {job_id}")
|
|
321
|
+
raise HTTPException(
|
|
322
|
+
status_code=401,
|
|
323
|
+
detail="Review token has expired. Please request a new review link."
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
logger.info(f"Review access granted via review_token for job {job_id}")
|
|
327
|
+
return job_id, "review_token"
|
|
328
|
+
else:
|
|
329
|
+
logger.warning(f"Invalid review token for job {job_id}")
|
|
330
|
+
|
|
331
|
+
# No valid authentication
|
|
332
|
+
raise HTTPException(
|
|
333
|
+
status_code=401,
|
|
334
|
+
detail="Authentication required. Provide either a full access token or a valid review_token for this job."
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
return require_review_auth
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# Default instance for most review endpoints
|
|
341
|
+
require_review_auth = require_review_auth_factory()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def require_instrumental_auth_factory(job_id_param: str = "job_id"):
|
|
345
|
+
"""
|
|
346
|
+
Factory to create an instrumental review authentication dependency.
|
|
347
|
+
|
|
348
|
+
Accepts either:
|
|
349
|
+
1. Full user authentication (admin/user token) - also validates job ownership
|
|
350
|
+
2. Job-specific instrumental token (only valid for the specific job)
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
job_id_param: Name of the path parameter containing the job ID
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Dependency function that validates instrumental review access
|
|
357
|
+
"""
|
|
358
|
+
async def require_instrumental_auth(
|
|
359
|
+
request: Request,
|
|
360
|
+
job_id: str = Path(...),
|
|
361
|
+
instrumental_token: Optional[str] = Query(None, description="Job-specific instrumental review token"),
|
|
362
|
+
auth_service: AuthService = Depends(get_auth_service),
|
|
363
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
364
|
+
token: Optional[str] = Query(None, alias="token", description="Full access token")
|
|
365
|
+
) -> Tuple[str, str]:
|
|
366
|
+
"""
|
|
367
|
+
Validate instrumental review access for a job.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
(job_id, auth_type) where auth_type is "full" or "instrumental_token"
|
|
371
|
+
|
|
372
|
+
Raises:
|
|
373
|
+
HTTPException: 401 if authentication fails
|
|
374
|
+
HTTPException: 403 if user doesn't own the job (for full auth)
|
|
375
|
+
"""
|
|
376
|
+
# Import here to avoid circular dependency
|
|
377
|
+
from backend.services.job_manager import JobManager
|
|
378
|
+
|
|
379
|
+
job_manager = JobManager()
|
|
380
|
+
|
|
381
|
+
# Try full authentication first
|
|
382
|
+
full_token = None
|
|
383
|
+
if credentials:
|
|
384
|
+
full_token = credentials.credentials
|
|
385
|
+
elif token:
|
|
386
|
+
full_token = token
|
|
387
|
+
|
|
388
|
+
if full_token:
|
|
389
|
+
auth_result = auth_service.validate_token_full(full_token)
|
|
390
|
+
if auth_result.is_valid:
|
|
391
|
+
# For full auth, also verify job ownership
|
|
392
|
+
job = job_manager.get_job(job_id)
|
|
393
|
+
if not job:
|
|
394
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
395
|
+
|
|
396
|
+
# Check ownership: admin can access all, users only their own jobs
|
|
397
|
+
if not auth_result.is_admin:
|
|
398
|
+
if auth_result.user_email and job.user_email:
|
|
399
|
+
if auth_result.user_email.lower() != job.user_email.lower():
|
|
400
|
+
logger.warning(
|
|
401
|
+
f"Instrumental access denied: user {auth_result.user_email} "
|
|
402
|
+
f"tried to access job {job_id} owned by {job.user_email}"
|
|
403
|
+
)
|
|
404
|
+
raise HTTPException(
|
|
405
|
+
status_code=403,
|
|
406
|
+
detail="You don't have permission to access this job's instrumental review"
|
|
407
|
+
)
|
|
408
|
+
elif job.user_email:
|
|
409
|
+
# Token auth without email trying to access a job with owner
|
|
410
|
+
raise HTTPException(
|
|
411
|
+
status_code=403,
|
|
412
|
+
detail="You don't have permission to access this job's instrumental review"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
logger.info(f"Instrumental access granted via full auth ({auth_result.user_type}) for job {job_id}")
|
|
416
|
+
return job_id, "full"
|
|
417
|
+
|
|
418
|
+
# Try instrumental token
|
|
419
|
+
if instrumental_token:
|
|
420
|
+
job = job_manager.get_job(job_id)
|
|
421
|
+
|
|
422
|
+
if not job:
|
|
423
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
424
|
+
|
|
425
|
+
# Validate instrumental token matches
|
|
426
|
+
if job.instrumental_token and secrets.compare_digest(job.instrumental_token, instrumental_token):
|
|
427
|
+
# Check expiry if set
|
|
428
|
+
if job.instrumental_token_expires_at:
|
|
429
|
+
now = datetime.now(UTC)
|
|
430
|
+
# Handle timezone-naive datetimes from Firestore
|
|
431
|
+
expiry = job.instrumental_token_expires_at
|
|
432
|
+
if expiry.tzinfo is None:
|
|
433
|
+
expiry = expiry.replace(tzinfo=UTC)
|
|
434
|
+
|
|
435
|
+
if now > expiry:
|
|
436
|
+
logger.warning(f"Instrumental token expired for job {job_id}")
|
|
437
|
+
raise HTTPException(
|
|
438
|
+
status_code=401,
|
|
439
|
+
detail="Instrumental review token has expired. Please request a new review link."
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
logger.info(f"Instrumental access granted via instrumental_token for job {job_id}")
|
|
443
|
+
return job_id, "instrumental_token"
|
|
444
|
+
else:
|
|
445
|
+
logger.warning(f"Invalid instrumental token for job {job_id}")
|
|
446
|
+
|
|
447
|
+
# No valid authentication
|
|
448
|
+
raise HTTPException(
|
|
449
|
+
status_code=401,
|
|
450
|
+
detail="Authentication required. Provide either a full access token or a valid instrumental_token for this job."
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
return require_instrumental_auth
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# Default instance for most instrumental review endpoints
|
|
457
|
+
require_instrumental_auth = require_instrumental_auth_factory()
|
|
File without changes
|