deploy: sync backend to Space root (learn-lesson HF cache fix)
Browse files- .env.example +6 -5
- README.md +4 -3
- STORAGE_SETUP.md +3 -1
- app/core/auth.py +15 -7
- app/core/config.py +7 -4
- app/main.py +21 -52
- app/routes/auth.py +86 -13
- app/routes/generated_media.py +60 -0
- app/routes/health.py +3 -1
- app/routes/learning_engine.py +1 -0
- app/routes/transcription.py +29 -11
- app/routes/video.py +1 -2
- app/schemas/source.py +14 -10
- app/schemas/video.py +54 -43
- app/services/account_deletion.py +11 -0
- app/services/learn_lesson_builder.py +31 -34
- app/services/storage_provider.py +18 -2
- app/services/tts_provider.py +4 -24
- app/services/video_jobs.py +1 -0
- app/services/video_renderer.py +20 -43
- app/services/video_study_preview_renderer.py +1 -0
- tests/conftest.py +4 -1
- tests/test_data_isolation.py +1 -1
- tests/test_google_auth.py +37 -2
- tests/test_launch_blocker_security.py +9 -1
- tests/test_security_scan_regressions.py +67 -0
- tests/test_study_video_preview_render.py +3 -1
.env.example
CHANGED
|
@@ -110,7 +110,7 @@ VIDEO_MAX_CONCURRENT_RENDER_JOBS_PER_USER="1"
|
|
| 110 |
VIDEO_ENFORCE_BETA_LIMITS_IN_DEVELOPMENT="false"
|
| 111 |
|
| 112 |
# Storage provider settings.
|
| 113 |
-
# Local
|
| 114 |
# Use STORAGE_PROVIDER="r2" or "s3" with an S3-compatible bucket for cloud media.
|
| 115 |
STORAGE_PROVIDER="local"
|
| 116 |
STORAGE_BUCKET=""
|
|
@@ -124,9 +124,10 @@ STORAGE_AUDIO_PREFIX="generated-audio"
|
|
| 124 |
STORAGE_VIDEO_PREFIX="generated-videos"
|
| 125 |
|
| 126 |
# Auth settings.
|
| 127 |
-
#
|
| 128 |
AUTH_ENABLED="false"
|
| 129 |
AUTH_PROVIDER="dev"
|
|
|
|
| 130 |
FRONTEND_BASE_URL="http://127.0.0.1:3000"
|
| 131 |
JWT_SECRET_KEY="change-this-before-real-users"
|
| 132 |
JWT_ALGORITHM="HS256"
|
|
@@ -160,9 +161,9 @@ BETA_ACCESS_ENABLED="false"
|
|
| 160 |
BETA_INVITE_CODE=""
|
| 161 |
|
| 162 |
# Per-user AI rate limiting.
|
| 163 |
-
# Set RATE_LIMIT_ENABLED=true
|
| 164 |
-
#
|
| 165 |
-
#
|
| 166 |
RATE_LIMIT_ENABLED="false"
|
| 167 |
RATE_LIMIT_AI_REQUESTS_PER_DAY="50"
|
| 168 |
|
|
|
|
| 110 |
VIDEO_ENFORCE_BETA_LIMITS_IN_DEVELOPMENT="false"
|
| 111 |
|
| 112 |
# Storage provider settings.
|
| 113 |
+
# Local generated media is delivered through authenticated /generated routes.
|
| 114 |
# Use STORAGE_PROVIDER="r2" or "s3" with an S3-compatible bucket for cloud media.
|
| 115 |
STORAGE_PROVIDER="local"
|
| 116 |
STORAGE_BUCKET=""
|
|
|
|
| 124 |
STORAGE_VIDEO_PREFIX="generated-videos"
|
| 125 |
|
| 126 |
# Auth settings.
|
| 127 |
+
# Demo auth is permitted only when explicitly enabled for localhost development.
|
| 128 |
AUTH_ENABLED="false"
|
| 129 |
AUTH_PROVIDER="dev"
|
| 130 |
+
ALLOW_INSECURE_DEV_AUTH="true"
|
| 131 |
FRONTEND_BASE_URL="http://127.0.0.1:3000"
|
| 132 |
JWT_SECRET_KEY="change-this-before-real-users"
|
| 133 |
JWT_ALGORITHM="HS256"
|
|
|
|
| 161 |
BETA_INVITE_CODE=""
|
| 162 |
|
| 163 |
# Per-user AI rate limiting.
|
| 164 |
+
# Set RATE_LIMIT_ENABLED=true outside local development. It prevents a single
|
| 165 |
+
# user from exhausting credits and applies per user (JWT sub) or server-observed
|
| 166 |
+
# client IP when no token is present. Resets every 24 hours from first request.
|
| 167 |
RATE_LIMIT_ENABLED="false"
|
| 168 |
RATE_LIMIT_AI_REQUESTS_PER_DAY="50"
|
| 169 |
|
README.md
CHANGED
|
@@ -44,9 +44,10 @@ Local development keeps auth friction low by default:
|
|
| 44 |
```powershell
|
| 45 |
AUTH_ENABLED="false"
|
| 46 |
AUTH_PROVIDER="dev"
|
|
|
|
| 47 |
```
|
| 48 |
|
| 49 |
-
|
| 50 |
|
| 51 |
JWT mode enables real email/password signup and login through the FastAPI backend:
|
| 52 |
|
|
@@ -265,8 +266,8 @@ Open:
|
|
| 265 |
- `GET /video/render-jobs`
|
| 266 |
- `GET /video/render-jobs/{job_id}`
|
| 267 |
- `DELETE /video/render-jobs/{job_id}`
|
| 268 |
-
- `GET /generated/audio/{video_id}/scene-001.wav`
|
| 269 |
-
- `GET /generated/videos/{file_name}.mp4`
|
| 270 |
|
| 271 |
## Local demo seed
|
| 272 |
|
|
|
|
| 44 |
```powershell
|
| 45 |
AUTH_ENABLED="false"
|
| 46 |
AUTH_PROVIDER="dev"
|
| 47 |
+
ALLOW_INSECURE_DEV_AUTH="true"
|
| 48 |
```
|
| 49 |
|
| 50 |
+
This is deliberately limited to local development. Set `ALLOW_INSECURE_DEV_AUTH=true` only with a localhost frontend; any other deployment must enable real authentication.
|
| 51 |
|
| 52 |
JWT mode enables real email/password signup and login through the FastAPI backend:
|
| 53 |
|
|
|
|
| 266 |
- `GET /video/render-jobs`
|
| 267 |
- `GET /video/render-jobs/{job_id}`
|
| 268 |
- `DELETE /video/render-jobs/{job_id}`
|
| 269 |
+
- `GET /generated/audio/{user_id}/{video_id}/scene-001.wav` (owner-authenticated)
|
| 270 |
+
- `GET /generated/videos/{user_id}/{file_name}.mp4` (owner-authenticated)
|
| 271 |
|
| 272 |
## Local demo seed
|
| 273 |
|
STORAGE_SETUP.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
# Cloud Storage Setup (R2 / S3-compatible)
|
| 2 |
|
| 3 |
-
DocDoe
|
|
|
|
|
|
|
| 4 |
|
| 5 |
## When to switch
|
| 6 |
|
|
|
|
| 1 |
# Cloud Storage Setup (R2 / S3-compatible)
|
| 2 |
|
| 3 |
+
DocDoe serves generated student audio and video through owner-authenticated API
|
| 4 |
+
routes. Keep any backing object store private; public bucket URLs are not an
|
| 5 |
+
acceptable delivery mechanism for student-generated artifacts.
|
| 6 |
|
| 7 |
## When to switch
|
| 8 |
|
app/core/auth.py
CHANGED
|
@@ -8,7 +8,7 @@ from datetime import datetime, timedelta, timezone
|
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
import jwt
|
| 11 |
-
from fastapi import Depends, HTTPException, status
|
| 12 |
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 13 |
from sqlalchemy import select
|
| 14 |
from sqlalchemy.orm import Session
|
|
@@ -20,25 +20,33 @@ from app.models.user import User
|
|
| 20 |
|
| 21 |
bearer_scheme = HTTPBearer(auto_error=False)
|
| 22 |
DEMO_PREVIEW_TOKEN = "docdoe-demo-preview-token"
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
def get_current_user_optional(
|
|
|
|
| 26 |
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
| 27 |
db: Session = Depends(get_db),
|
| 28 |
) -> User | None:
|
| 29 |
settings = get_settings()
|
| 30 |
if not settings.auth_enabled:
|
| 31 |
-
if
|
|
|
|
|
|
|
|
|
|
| 32 |
raise HTTPException(
|
| 33 |
-
status_code=status.
|
| 34 |
-
detail="Authentication is
|
| 35 |
)
|
| 36 |
return get_or_create_dev_user(db)
|
| 37 |
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
return None
|
| 40 |
-
|
| 41 |
-
token = credentials.credentials
|
| 42 |
if token == DEMO_PREVIEW_TOKEN and settings.environment == "development":
|
| 43 |
return get_or_create_dev_user(db)
|
| 44 |
|
|
|
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
import jwt
|
| 11 |
+
from fastapi import Depends, HTTPException, Request, status
|
| 12 |
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 13 |
from sqlalchemy import select
|
| 14 |
from sqlalchemy.orm import Session
|
|
|
|
| 20 |
|
| 21 |
bearer_scheme = HTTPBearer(auto_error=False)
|
| 22 |
DEMO_PREVIEW_TOKEN = "docdoe-demo-preview-token"
|
| 23 |
+
MEDIA_AUTH_COOKIE = "docdoe_media_token"
|
| 24 |
|
| 25 |
|
| 26 |
def get_current_user_optional(
|
| 27 |
+
request: Request,
|
| 28 |
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
| 29 |
db: Session = Depends(get_db),
|
| 30 |
) -> User | None:
|
| 31 |
settings = get_settings()
|
| 32 |
if not settings.auth_enabled:
|
| 33 |
+
if not (
|
| 34 |
+
settings.environment == "development"
|
| 35 |
+
and settings.allow_insecure_dev_auth
|
| 36 |
+
):
|
| 37 |
raise HTTPException(
|
| 38 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 39 |
+
detail="Authentication is not configured for this deployment.",
|
| 40 |
)
|
| 41 |
return get_or_create_dev_user(db)
|
| 42 |
|
| 43 |
+
token = (
|
| 44 |
+
credentials.credentials
|
| 45 |
+
if credentials is not None and credentials.scheme.lower() == "bearer"
|
| 46 |
+
else request.cookies.get(MEDIA_AUTH_COOKIE)
|
| 47 |
+
)
|
| 48 |
+
if not token:
|
| 49 |
return None
|
|
|
|
|
|
|
| 50 |
if token == DEMO_PREVIEW_TOKEN and settings.environment == "development":
|
| 51 |
return get_or_create_dev_user(db)
|
| 52 |
|
app/core/config.py
CHANGED
|
@@ -228,8 +228,11 @@ class Settings(BaseSettings):
|
|
| 228 |
cloudinary_cloud_name: str | None = None
|
| 229 |
cloudinary_api_key: str | None = None
|
| 230 |
cloudinary_api_secret: str | None = None
|
| 231 |
-
auth_provider: str = "
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
| 233 |
frontend_base_url: str = "http://127.0.0.1:3000"
|
| 234 |
google_client_id: str | None = None
|
| 235 |
google_client_secret: str | None = None
|
|
@@ -244,14 +247,14 @@ class Settings(BaseSettings):
|
|
| 244 |
default=None,
|
| 245 |
validation_alias=AliasChoices("SUPABASE_SERVICE_ROLE_KEY"),
|
| 246 |
)
|
| 247 |
-
jwt_secret_key: str =
|
| 248 |
jwt_algorithm: str = "HS256"
|
| 249 |
access_token_expire_minutes: int = 10080 # 7 days — keeps users logged in across sessions
|
| 250 |
# --- Beta invite gate ---
|
| 251 |
beta_access_enabled: bool = False
|
| 252 |
beta_invite_code: str | None = None
|
| 253 |
# --- Per-user AI rate limiting ---
|
| 254 |
-
rate_limit_enabled: bool =
|
| 255 |
rate_limit_ai_requests_per_day: int = 50
|
| 256 |
# --- Admin access ---
|
| 257 |
admin_emails: str = "" # comma-separated list of admin email addresses
|
|
|
|
| 228 |
cloudinary_cloud_name: str | None = None
|
| 229 |
cloudinary_api_key: str | None = None
|
| 230 |
cloudinary_api_secret: str | None = None
|
| 231 |
+
auth_provider: str = "jwt"
|
| 232 |
+
# Local preview is an explicit opt-in so a non-production deployment cannot
|
| 233 |
+
# become a shared demo account by accident.
|
| 234 |
+
auth_enabled: bool = True
|
| 235 |
+
allow_insecure_dev_auth: bool = False
|
| 236 |
frontend_base_url: str = "http://127.0.0.1:3000"
|
| 237 |
google_client_id: str | None = None
|
| 238 |
google_client_secret: str | None = None
|
|
|
|
| 247 |
default=None,
|
| 248 |
validation_alias=AliasChoices("SUPABASE_SERVICE_ROLE_KEY"),
|
| 249 |
)
|
| 250 |
+
jwt_secret_key: str | None = None
|
| 251 |
jwt_algorithm: str = "HS256"
|
| 252 |
access_token_expire_minutes: int = 10080 # 7 days — keeps users logged in across sessions
|
| 253 |
# --- Beta invite gate ---
|
| 254 |
beta_access_enabled: bool = False
|
| 255 |
beta_invite_code: str | None = None
|
| 256 |
# --- Per-user AI rate limiting ---
|
| 257 |
+
rate_limit_enabled: bool = True
|
| 258 |
rate_limit_ai_requests_per_day: int = 50
|
| 259 |
# --- Admin access ---
|
| 260 |
admin_emails: str = "" # comma-separated list of admin email addresses
|
app/main.py
CHANGED
|
@@ -1,17 +1,13 @@
|
|
| 1 |
from collections.abc import AsyncIterator
|
| 2 |
from contextlib import asynccontextmanager
|
| 3 |
-
from pathlib import Path
|
| 4 |
-
|
| 5 |
import logging
|
| 6 |
import time
|
| 7 |
-
import uuid
|
| 8 |
|
| 9 |
import jwt as _jwt
|
| 10 |
from fastapi import FastAPI, Request
|
| 11 |
from fastapi.exceptions import RequestValidationError
|
| 12 |
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
from fastapi.responses import JSONResponse
|
| 14 |
-
from fastapi.staticfiles import StaticFiles
|
| 15 |
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 16 |
|
| 17 |
from app.core import rate_limiter
|
|
@@ -32,6 +28,7 @@ from app.routes import (
|
|
| 32 |
documents,
|
| 33 |
flashcards,
|
| 34 |
generations,
|
|
|
|
| 35 |
generate_studio,
|
| 36 |
health,
|
| 37 |
intelligence,
|
|
@@ -99,8 +96,6 @@ def _init_sentry() -> None:
|
|
| 99 |
_init_sentry()
|
| 100 |
|
| 101 |
# Default JWT secret — used to detect unsafe production configs.
|
| 102 |
-
_DEFAULT_JWT_SECRET = "change-this-local-dev-secret"
|
| 103 |
-
|
| 104 |
# Paths that consume AI credits and are subject to per-user rate limiting.
|
| 105 |
_RATE_LIMIT_PREFIXES = (
|
| 106 |
"/ask",
|
|
@@ -119,6 +114,9 @@ _RATE_LIMIT_PREFIXES = (
|
|
| 119 |
"/video-generator/plan",
|
| 120 |
"/study-path/generate",
|
| 121 |
"/support",
|
|
|
|
|
|
|
|
|
|
| 122 |
)
|
| 123 |
|
| 124 |
# Rate-limit counters live in app.core.rate_limiter (Redis-shared when REDIS_URL
|
|
@@ -195,24 +193,20 @@ def _startup_safety_checks() -> None:
|
|
| 195 |
is_prod = current_settings.environment == "production"
|
| 196 |
|
| 197 |
# ── JWT secret ──────────────────────────────────────────────────────────
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
"JWT_SECRET_KEY must be changed before running in production. "
|
| 206 |
-
"Set JWT_SECRET_KEY to a random 32+ character string in backend/.env."
|
| 207 |
-
)
|
| 208 |
-
else:
|
| 209 |
-
logger.warning(
|
| 210 |
-
"SECURITY WARNING: JWT_SECRET_KEY is the default development value. "
|
| 211 |
-
"Change it before deploying to any environment with real users."
|
| 212 |
-
)
|
| 213 |
|
| 214 |
# ── Auth disabled in production ──────────────────────────────────────────
|
| 215 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
logger.error(
|
| 217 |
"SECURITY: AUTH_ENABLED is false in production. "
|
| 218 |
"Every API request will be served as the dev/demo user — "
|
|
@@ -287,9 +281,9 @@ def _startup_safety_checks() -> None:
|
|
| 287 |
)
|
| 288 |
|
| 289 |
# ── Rate limiting disabled in production ─────────────────────────────────
|
| 290 |
-
if
|
| 291 |
-
|
| 292 |
-
"
|
| 293 |
"Per-user AI quota is not enforced — a single user can exhaust Sarvam credits. "
|
| 294 |
"Set RATE_LIMIT_ENABLED=true and RATE_LIMIT_AI_REQUESTS_PER_DAY=50."
|
| 295 |
)
|
|
@@ -418,12 +412,7 @@ async def security_headers_middleware(request: Request, call_next):
|
|
| 418 |
|
| 419 |
|
| 420 |
def _ip_rate_limit_key(request: Request) -> str:
|
| 421 |
-
|
| 422 |
-
ip = (
|
| 423 |
-
forwarded.split(",")[0].strip()
|
| 424 |
-
if forwarded
|
| 425 |
-
else (request.client.host if request.client else "unknown")
|
| 426 |
-
)
|
| 427 |
return f"ip:{ip}"
|
| 428 |
|
| 429 |
|
|
@@ -769,24 +758,4 @@ app.include_router(video_generator.router, prefix="/video-generator", tags=["Vid
|
|
| 769 |
app.include_router(billing.router, prefix="/billing", tags=["Billing"])
|
| 770 |
app.include_router(intelligence.router, prefix="/intelligence", tags=["Intelligence"])
|
| 771 |
app.include_router(dashboard.router, prefix="/dashboard", tags=["Dashboard"])
|
| 772 |
-
app.
|
| 773 |
-
"/generated/audio",
|
| 774 |
-
StaticFiles(directory=settings.resolved_tts_output_dir, check_dir=False),
|
| 775 |
-
name="generated-audio",
|
| 776 |
-
)
|
| 777 |
-
app.mount(
|
| 778 |
-
"/generated/videos",
|
| 779 |
-
StaticFiles(directory=settings.resolved_generated_video_output_dir, check_dir=False),
|
| 780 |
-
name="generated-videos",
|
| 781 |
-
)
|
| 782 |
-
# Learn Anything lesson cache (manifests + per-beat audio). Must live under a
|
| 783 |
-
# writable path on Hugging Face (/app/generated/...), not monorepo public/.
|
| 784 |
-
_learn_lesson_static = (
|
| 785 |
-
Path(__file__).resolve().parents[1] / "generated" / "learn-anything"
|
| 786 |
-
)
|
| 787 |
-
_learn_lesson_static.mkdir(parents=True, exist_ok=True)
|
| 788 |
-
app.mount(
|
| 789 |
-
"/generated/learn-anything",
|
| 790 |
-
StaticFiles(directory=_learn_lesson_static, check_dir=False),
|
| 791 |
-
name="generated-learn-lessons",
|
| 792 |
-
)
|
|
|
|
| 1 |
from collections.abc import AsyncIterator
|
| 2 |
from contextlib import asynccontextmanager
|
|
|
|
|
|
|
| 3 |
import logging
|
| 4 |
import time
|
|
|
|
| 5 |
|
| 6 |
import jwt as _jwt
|
| 7 |
from fastapi import FastAPI, Request
|
| 8 |
from fastapi.exceptions import RequestValidationError
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
from fastapi.responses import JSONResponse
|
|
|
|
| 11 |
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 12 |
|
| 13 |
from app.core import rate_limiter
|
|
|
|
| 28 |
documents,
|
| 29 |
flashcards,
|
| 30 |
generations,
|
| 31 |
+
generated_media,
|
| 32 |
generate_studio,
|
| 33 |
health,
|
| 34 |
intelligence,
|
|
|
|
| 96 |
_init_sentry()
|
| 97 |
|
| 98 |
# Default JWT secret — used to detect unsafe production configs.
|
|
|
|
|
|
|
| 99 |
# Paths that consume AI credits and are subject to per-user rate limiting.
|
| 100 |
_RATE_LIMIT_PREFIXES = (
|
| 101 |
"/ask",
|
|
|
|
| 114 |
"/video-generator/plan",
|
| 115 |
"/study-path/generate",
|
| 116 |
"/support",
|
| 117 |
+
"/sources",
|
| 118 |
+
"/record-lecture",
|
| 119 |
+
"/telemetry",
|
| 120 |
)
|
| 121 |
|
| 122 |
# Rate-limit counters live in app.core.rate_limiter (Redis-shared when REDIS_URL
|
|
|
|
| 193 |
is_prod = current_settings.environment == "production"
|
| 194 |
|
| 195 |
# ── JWT secret ──────────────────────────────────────────────────────────
|
| 196 |
+
uses_local_jwt = (current_settings.auth_provider or "jwt").strip().lower() == "jwt"
|
| 197 |
+
if uses_local_jwt and (
|
| 198 |
+
not current_settings.jwt_secret_key or len(current_settings.jwt_secret_key) < 32
|
| 199 |
+
):
|
| 200 |
+
raise RuntimeError(
|
| 201 |
+
"JWT_SECRET_KEY must be a unique 32+ character secret whenever AUTH_PROVIDER=jwt."
|
| 202 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
# ── Auth disabled in production ──────────────────────────────────────────
|
| 205 |
+
if not current_settings.auth_enabled and not (
|
| 206 |
+
current_settings.environment == "development"
|
| 207 |
+
and current_settings.allow_insecure_dev_auth
|
| 208 |
+
and current_settings.frontend_base_url.startswith(("http://localhost", "http://127.0.0.1"))
|
| 209 |
+
):
|
| 210 |
logger.error(
|
| 211 |
"SECURITY: AUTH_ENABLED is false in production. "
|
| 212 |
"Every API request will be served as the dev/demo user — "
|
|
|
|
| 281 |
)
|
| 282 |
|
| 283 |
# ── Rate limiting disabled in production ─────────────────────────────────
|
| 284 |
+
if current_settings.environment != "development" and not current_settings.rate_limit_enabled:
|
| 285 |
+
raise RuntimeError(
|
| 286 |
+
"RATE_LIMIT_ENABLED must be true outside local development. "
|
| 287 |
"Per-user AI quota is not enforced — a single user can exhaust Sarvam credits. "
|
| 288 |
"Set RATE_LIMIT_ENABLED=true and RATE_LIMIT_AI_REQUESTS_PER_DAY=50."
|
| 289 |
)
|
|
|
|
| 412 |
|
| 413 |
|
| 414 |
def _ip_rate_limit_key(request: Request) -> str:
|
| 415 |
+
ip = request.client.host if request.client else "unknown"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
return f"ip:{ip}"
|
| 417 |
|
| 418 |
|
|
|
|
| 758 |
app.include_router(billing.router, prefix="/billing", tags=["Billing"])
|
| 759 |
app.include_router(intelligence.router, prefix="/intelligence", tags=["Intelligence"])
|
| 760 |
app.include_router(dashboard.router, prefix="/dashboard", tags=["Dashboard"])
|
| 761 |
+
app.include_router(generated_media.router, prefix="/generated", tags=["Generated media"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/routes/auth.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
import json
|
| 4 |
import hashlib
|
|
|
|
| 5 |
import logging
|
| 6 |
import secrets
|
| 7 |
import urllib.error
|
|
@@ -10,12 +11,18 @@ import urllib.request
|
|
| 10 |
from datetime import datetime, timedelta, timezone
|
| 11 |
|
| 12 |
import jwt
|
| 13 |
-
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
| 14 |
from fastapi.responses import RedirectResponse
|
| 15 |
from sqlalchemy import delete, select, update
|
| 16 |
from sqlalchemy.orm import Session
|
| 17 |
|
| 18 |
-
from app.core.auth import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
from app.core.config import get_settings
|
| 20 |
from app.core.database import get_db
|
| 21 |
from app.models.user import User
|
|
@@ -53,6 +60,8 @@ LOGIN_OTP_MESSAGE = (
|
|
| 53 |
"If an account exists for that email, DocDoe will send a 6-digit sign-in code."
|
| 54 |
)
|
| 55 |
LOGIN_OTP_TTL_MINUTES = 10
|
|
|
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
def _validate_password(password: str) -> None:
|
|
@@ -117,13 +126,16 @@ def _google_redirect_uri(request: Request) -> str:
|
|
| 117 |
return settings.google_oauth_redirect_uri or str(request.url_for("google_callback"))
|
| 118 |
|
| 119 |
|
| 120 |
-
def _create_google_state(
|
|
|
|
|
|
|
| 121 |
settings = get_settings()
|
| 122 |
expires_at = datetime.now(timezone.utc) + timedelta(minutes=10)
|
| 123 |
payload = {
|
| 124 |
"typ": "google_oauth_state",
|
| 125 |
"next": _safe_next_path(next_path),
|
| 126 |
"invite_code": invite_code or None,
|
|
|
|
| 127 |
"exp": expires_at,
|
| 128 |
"iat": datetime.now(timezone.utc),
|
| 129 |
}
|
|
@@ -152,9 +164,23 @@ def _decode_google_state(state_token: str) -> dict[str, str | None]:
|
|
| 152 |
return {
|
| 153 |
"next": _safe_next_path(str(payload.get("next") or "/dashboard")),
|
| 154 |
"invite_code": payload.get("invite_code"),
|
|
|
|
| 155 |
}
|
| 156 |
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
def _post_google_token(payload: dict[str, str]) -> dict:
|
| 159 |
data = urllib.parse.urlencode(payload).encode("utf-8")
|
| 160 |
request = urllib.request.Request(
|
|
@@ -206,7 +232,7 @@ def _user_from_google_profile(db: Session, profile: dict, invite_code: str | Non
|
|
| 206 |
if existing_user is not None:
|
| 207 |
return existing_user
|
| 208 |
|
| 209 |
-
|
| 210 |
name = str(profile.get("name") or profile.get("given_name") or email.split("@")[0]).strip()
|
| 211 |
user = User(
|
| 212 |
name=name or "Student",
|
|
@@ -224,7 +250,7 @@ def _user_from_google_profile(db: Session, profile: dict, invite_code: str | Non
|
|
| 224 |
def _auth_redirect_for_user(user: User, next_path: str) -> RedirectResponse:
|
| 225 |
token, expires_in = create_access_token(user)
|
| 226 |
user_payload = UserRead.model_validate(user).model_dump(mode="json")
|
| 227 |
-
|
| 228 |
_frontend_callback_url(
|
| 229 |
{
|
| 230 |
"access_token": token,
|
|
@@ -236,13 +262,30 @@ def _auth_redirect_for_user(user: User, next_path: str) -> RedirectResponse:
|
|
| 236 |
),
|
| 237 |
status_code=status.HTTP_302_FOUND,
|
| 238 |
)
|
|
|
|
|
|
|
| 239 |
|
| 240 |
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
# Normalize email �� case-insensitive lookup AND storage so "User@Mail.com"
|
| 247 |
# and "user@mail.com" can't both register as separate accounts.
|
| 248 |
normalized_email = payload.email.strip().lower()
|
|
@@ -259,6 +302,7 @@ def signup(payload: AuthSignupRequest, db: Session = Depends(get_db)) -> AuthTok
|
|
| 259 |
status_code=status.HTTP_409_CONFLICT,
|
| 260 |
detail="Email already exists",
|
| 261 |
)
|
|
|
|
| 262 |
|
| 263 |
user = User(
|
| 264 |
name=payload.name,
|
|
@@ -275,6 +319,7 @@ def signup(payload: AuthSignupRequest, db: Session = Depends(get_db)) -> AuthTok
|
|
| 275 |
db.commit()
|
| 276 |
db.refresh(user)
|
| 277 |
token, expires_in = create_access_token(user)
|
|
|
|
| 278 |
return AuthTokenResponse(
|
| 279 |
access_token=token,
|
| 280 |
expires_in=expires_in,
|
|
@@ -283,7 +328,11 @@ def signup(payload: AuthSignupRequest, db: Session = Depends(get_db)) -> AuthTok
|
|
| 283 |
|
| 284 |
|
| 285 |
@router.post("/login", response_model=AuthTokenResponse)
|
| 286 |
-
def login(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
# Match the normalized lower-case email stored at signup time so users with a
|
| 288 |
# mixed-case email entry can still sign in.
|
| 289 |
normalized_email = payload.email.strip().lower()
|
|
@@ -295,6 +344,7 @@ def login(payload: AuthLoginRequest, db: Session = Depends(get_db)) -> AuthToken
|
|
| 295 |
)
|
| 296 |
|
| 297 |
token, expires_in = create_access_token(user)
|
|
|
|
| 298 |
return AuthTokenResponse(
|
| 299 |
access_token=token,
|
| 300 |
expires_in=expires_in,
|
|
@@ -377,6 +427,7 @@ def request_login_otp(
|
|
| 377 |
@router.post("/login/otp/verify", response_model=AuthTokenResponse)
|
| 378 |
def verify_login_otp(
|
| 379 |
payload: LoginOtpVerifyRequest,
|
|
|
|
| 380 |
db: Session = Depends(get_db),
|
| 381 |
) -> AuthTokenResponse:
|
| 382 |
if (get_settings().auth_provider or "jwt").strip().lower() != "jwt":
|
|
@@ -422,6 +473,7 @@ def verify_login_otp(
|
|
| 422 |
db.commit()
|
| 423 |
|
| 424 |
access_token, expires_in = create_access_token(user)
|
|
|
|
| 425 |
return AuthTokenResponse(
|
| 426 |
access_token=access_token,
|
| 427 |
expires_in=expires_in,
|
|
@@ -570,7 +622,10 @@ def google_start(
|
|
| 570 |
if not settings.google_client_id or not settings.google_client_secret:
|
| 571 |
return _redirect_with_google_error("Google sign-in is not ready right now.", next)
|
| 572 |
|
| 573 |
-
|
|
|
|
|
|
|
|
|
|
| 574 |
params = {
|
| 575 |
"client_id": settings.google_client_id,
|
| 576 |
"redirect_uri": _google_redirect_uri(request),
|
|
@@ -580,10 +635,20 @@ def google_start(
|
|
| 580 |
"access_type": "online",
|
| 581 |
"prompt": "select_account",
|
| 582 |
}
|
| 583 |
-
|
| 584 |
f"{GOOGLE_AUTH_URL}?{urllib.parse.urlencode(params)}",
|
| 585 |
status_code=status.HTTP_302_FOUND,
|
| 586 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
|
| 588 |
|
| 589 |
@router.get("/google/callback", name="google_callback")
|
|
@@ -600,6 +665,12 @@ def google_callback(
|
|
| 600 |
return _redirect_with_google_error("Google sign-in expired. Please try again.")
|
| 601 |
state_payload = _decode_google_state(state)
|
| 602 |
next_path = str(state_payload.get("next") or "/dashboard")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
|
| 604 |
if error:
|
| 605 |
return _redirect_with_google_error("Google sign-in was cancelled.", next_path)
|
|
@@ -629,7 +700,9 @@ def google_callback(
|
|
| 629 |
profile,
|
| 630 |
invite_code=state_payload.get("invite_code"),
|
| 631 |
)
|
| 632 |
-
|
|
|
|
|
|
|
| 633 |
except HTTPException as exc:
|
| 634 |
message = str(exc.detail or "Could not finish Google sign-in right now.")
|
| 635 |
return _redirect_with_google_error(message, next_path)
|
|
|
|
| 2 |
|
| 3 |
import json
|
| 4 |
import hashlib
|
| 5 |
+
import hmac
|
| 6 |
import logging
|
| 7 |
import secrets
|
| 8 |
import urllib.error
|
|
|
|
| 11 |
from datetime import datetime, timedelta, timezone
|
| 12 |
|
| 13 |
import jwt
|
| 14 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
| 15 |
from fastapi.responses import RedirectResponse
|
| 16 |
from sqlalchemy import delete, select, update
|
| 17 |
from sqlalchemy.orm import Session
|
| 18 |
|
| 19 |
+
from app.core.auth import (
|
| 20 |
+
MEDIA_AUTH_COOKIE,
|
| 21 |
+
create_access_token,
|
| 22 |
+
hash_password,
|
| 23 |
+
require_user,
|
| 24 |
+
verify_password,
|
| 25 |
+
)
|
| 26 |
from app.core.config import get_settings
|
| 27 |
from app.core.database import get_db
|
| 28 |
from app.models.user import User
|
|
|
|
| 60 |
"If an account exists for that email, DocDoe will send a 6-digit sign-in code."
|
| 61 |
)
|
| 62 |
LOGIN_OTP_TTL_MINUTES = 10
|
| 63 |
+
GOOGLE_STATE_COOKIE = "docdoe_google_oauth_state"
|
| 64 |
+
GOOGLE_STATE_TTL_SECONDS = 10 * 60
|
| 65 |
|
| 66 |
|
| 67 |
def _validate_password(password: str) -> None:
|
|
|
|
| 126 |
return settings.google_oauth_redirect_uri or str(request.url_for("google_callback"))
|
| 127 |
|
| 128 |
|
| 129 |
+
def _create_google_state(
|
| 130 |
+
*, next_path: str, invite_code: str | None, nonce: str | None = None
|
| 131 |
+
) -> str:
|
| 132 |
settings = get_settings()
|
| 133 |
expires_at = datetime.now(timezone.utc) + timedelta(minutes=10)
|
| 134 |
payload = {
|
| 135 |
"typ": "google_oauth_state",
|
| 136 |
"next": _safe_next_path(next_path),
|
| 137 |
"invite_code": invite_code or None,
|
| 138 |
+
"nonce": nonce or secrets.token_urlsafe(32),
|
| 139 |
"exp": expires_at,
|
| 140 |
"iat": datetime.now(timezone.utc),
|
| 141 |
}
|
|
|
|
| 164 |
return {
|
| 165 |
"next": _safe_next_path(str(payload.get("next") or "/dashboard")),
|
| 166 |
"invite_code": payload.get("invite_code"),
|
| 167 |
+
"nonce": str(payload.get("nonce") or ""),
|
| 168 |
}
|
| 169 |
|
| 170 |
|
| 171 |
+
def _require_beta_invite(invite_code: str | None) -> None:
|
| 172 |
+
settings = get_settings()
|
| 173 |
+
if not settings.beta_access_enabled:
|
| 174 |
+
return
|
| 175 |
+
expected = settings.beta_invite_code or ""
|
| 176 |
+
supplied = (invite_code or "").strip()
|
| 177 |
+
if not expected or not supplied or not hmac.compare_digest(supplied, expected):
|
| 178 |
+
raise HTTPException(
|
| 179 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 180 |
+
detail="A valid beta invite is required to create an account.",
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
def _post_google_token(payload: dict[str, str]) -> dict:
|
| 185 |
data = urllib.parse.urlencode(payload).encode("utf-8")
|
| 186 |
request = urllib.request.Request(
|
|
|
|
| 232 |
if existing_user is not None:
|
| 233 |
return existing_user
|
| 234 |
|
| 235 |
+
_require_beta_invite(invite_code)
|
| 236 |
name = str(profile.get("name") or profile.get("given_name") or email.split("@")[0]).strip()
|
| 237 |
user = User(
|
| 238 |
name=name or "Student",
|
|
|
|
| 250 |
def _auth_redirect_for_user(user: User, next_path: str) -> RedirectResponse:
|
| 251 |
token, expires_in = create_access_token(user)
|
| 252 |
user_payload = UserRead.model_validate(user).model_dump(mode="json")
|
| 253 |
+
response = RedirectResponse(
|
| 254 |
_frontend_callback_url(
|
| 255 |
{
|
| 256 |
"access_token": token,
|
|
|
|
| 262 |
),
|
| 263 |
status_code=status.HTTP_302_FOUND,
|
| 264 |
)
|
| 265 |
+
_set_media_auth_cookie(response, token, expires_in)
|
| 266 |
+
return response
|
| 267 |
|
| 268 |
|
| 269 |
+
def _set_media_auth_cookie(response: Response, token: str, expires_in: int) -> None:
|
| 270 |
+
"""Allow browser media elements to authenticate without exposing a bearer token."""
|
| 271 |
+
settings = get_settings()
|
| 272 |
+
response.set_cookie(
|
| 273 |
+
key=MEDIA_AUTH_COOKIE,
|
| 274 |
+
value=token,
|
| 275 |
+
max_age=expires_in,
|
| 276 |
+
httponly=True,
|
| 277 |
+
secure=settings.is_production,
|
| 278 |
+
samesite="none" if settings.is_production else "lax",
|
| 279 |
+
path="/generated",
|
| 280 |
+
)
|
| 281 |
|
| 282 |
+
|
| 283 |
+
@router.post("/signup", response_model=AuthTokenResponse, status_code=status.HTTP_201_CREATED)
|
| 284 |
+
def signup(
|
| 285 |
+
payload: AuthSignupRequest,
|
| 286 |
+
response: Response,
|
| 287 |
+
db: Session = Depends(get_db),
|
| 288 |
+
) -> AuthTokenResponse:
|
| 289 |
# Normalize email �� case-insensitive lookup AND storage so "User@Mail.com"
|
| 290 |
# and "user@mail.com" can't both register as separate accounts.
|
| 291 |
normalized_email = payload.email.strip().lower()
|
|
|
|
| 302 |
status_code=status.HTTP_409_CONFLICT,
|
| 303 |
detail="Email already exists",
|
| 304 |
)
|
| 305 |
+
_require_beta_invite(payload.invite_code)
|
| 306 |
|
| 307 |
user = User(
|
| 308 |
name=payload.name,
|
|
|
|
| 319 |
db.commit()
|
| 320 |
db.refresh(user)
|
| 321 |
token, expires_in = create_access_token(user)
|
| 322 |
+
_set_media_auth_cookie(response, token, expires_in)
|
| 323 |
return AuthTokenResponse(
|
| 324 |
access_token=token,
|
| 325 |
expires_in=expires_in,
|
|
|
|
| 328 |
|
| 329 |
|
| 330 |
@router.post("/login", response_model=AuthTokenResponse)
|
| 331 |
+
def login(
|
| 332 |
+
payload: AuthLoginRequest,
|
| 333 |
+
response: Response,
|
| 334 |
+
db: Session = Depends(get_db),
|
| 335 |
+
) -> AuthTokenResponse:
|
| 336 |
# Match the normalized lower-case email stored at signup time so users with a
|
| 337 |
# mixed-case email entry can still sign in.
|
| 338 |
normalized_email = payload.email.strip().lower()
|
|
|
|
| 344 |
)
|
| 345 |
|
| 346 |
token, expires_in = create_access_token(user)
|
| 347 |
+
_set_media_auth_cookie(response, token, expires_in)
|
| 348 |
return AuthTokenResponse(
|
| 349 |
access_token=token,
|
| 350 |
expires_in=expires_in,
|
|
|
|
| 427 |
@router.post("/login/otp/verify", response_model=AuthTokenResponse)
|
| 428 |
def verify_login_otp(
|
| 429 |
payload: LoginOtpVerifyRequest,
|
| 430 |
+
response: Response,
|
| 431 |
db: Session = Depends(get_db),
|
| 432 |
) -> AuthTokenResponse:
|
| 433 |
if (get_settings().auth_provider or "jwt").strip().lower() != "jwt":
|
|
|
|
| 473 |
db.commit()
|
| 474 |
|
| 475 |
access_token, expires_in = create_access_token(user)
|
| 476 |
+
_set_media_auth_cookie(response, access_token, expires_in)
|
| 477 |
return AuthTokenResponse(
|
| 478 |
access_token=access_token,
|
| 479 |
expires_in=expires_in,
|
|
|
|
| 622 |
if not settings.google_client_id or not settings.google_client_secret:
|
| 623 |
return _redirect_with_google_error("Google sign-in is not ready right now.", next)
|
| 624 |
|
| 625 |
+
nonce = secrets.token_urlsafe(32)
|
| 626 |
+
state_token = _create_google_state(
|
| 627 |
+
next_path=next, invite_code=invite_code, nonce=nonce
|
| 628 |
+
)
|
| 629 |
params = {
|
| 630 |
"client_id": settings.google_client_id,
|
| 631 |
"redirect_uri": _google_redirect_uri(request),
|
|
|
|
| 635 |
"access_type": "online",
|
| 636 |
"prompt": "select_account",
|
| 637 |
}
|
| 638 |
+
response = RedirectResponse(
|
| 639 |
f"{GOOGLE_AUTH_URL}?{urllib.parse.urlencode(params)}",
|
| 640 |
status_code=status.HTTP_302_FOUND,
|
| 641 |
)
|
| 642 |
+
response.set_cookie(
|
| 643 |
+
GOOGLE_STATE_COOKIE,
|
| 644 |
+
nonce,
|
| 645 |
+
max_age=GOOGLE_STATE_TTL_SECONDS,
|
| 646 |
+
httponly=True,
|
| 647 |
+
secure=settings.environment == "production",
|
| 648 |
+
samesite="lax",
|
| 649 |
+
path="/auth/google",
|
| 650 |
+
)
|
| 651 |
+
return response
|
| 652 |
|
| 653 |
|
| 654 |
@router.get("/google/callback", name="google_callback")
|
|
|
|
| 665 |
return _redirect_with_google_error("Google sign-in expired. Please try again.")
|
| 666 |
state_payload = _decode_google_state(state)
|
| 667 |
next_path = str(state_payload.get("next") or "/dashboard")
|
| 668 |
+
cookie_nonce = request.cookies.get(GOOGLE_STATE_COOKIE, "")
|
| 669 |
+
state_nonce = str(state_payload.get("nonce") or "")
|
| 670 |
+
if not state_nonce or not cookie_nonce or not hmac.compare_digest(
|
| 671 |
+
state_nonce, cookie_nonce
|
| 672 |
+
):
|
| 673 |
+
return _redirect_with_google_error("Google sign-in expired. Please try again.", next_path)
|
| 674 |
|
| 675 |
if error:
|
| 676 |
return _redirect_with_google_error("Google sign-in was cancelled.", next_path)
|
|
|
|
| 700 |
profile,
|
| 701 |
invite_code=state_payload.get("invite_code"),
|
| 702 |
)
|
| 703 |
+
response = _auth_redirect_for_user(user, next_path)
|
| 704 |
+
response.delete_cookie(GOOGLE_STATE_COOKIE, path="/auth/google")
|
| 705 |
+
return response
|
| 706 |
except HTTPException as exc:
|
| 707 |
message = str(exc.detail or "Could not finish Google sign-in right now.")
|
| 708 |
return _redirect_with_google_error(message, next_path)
|
app/routes/generated_media.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authenticated delivery for user-generated local media artifacts."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 8 |
+
from fastapi.responses import FileResponse
|
| 9 |
+
|
| 10 |
+
from app.core.auth import require_user
|
| 11 |
+
from app.core.config import get_settings
|
| 12 |
+
from app.models.user import User
|
| 13 |
+
from app.services.learn_lesson_builder import ensure_public_root
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _owned_file(root: Path, artifact_path: str, user_id: str) -> Path:
|
| 20 |
+
candidate = (root / artifact_path).resolve()
|
| 21 |
+
try:
|
| 22 |
+
candidate.relative_to(root.resolve())
|
| 23 |
+
except ValueError as exc:
|
| 24 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) from exc
|
| 25 |
+
parts = Path(artifact_path).parts
|
| 26 |
+
if not parts or parts[0] != user_id or not candidate.is_file():
|
| 27 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
| 28 |
+
return candidate
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _private_file_response(path: Path) -> FileResponse:
|
| 32 |
+
return FileResponse(
|
| 33 |
+
path,
|
| 34 |
+
headers={
|
| 35 |
+
"Cache-Control": "private, no-store",
|
| 36 |
+
"Vary": "Authorization, Cookie",
|
| 37 |
+
},
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.get("/audio/{artifact_path:path}")
|
| 42 |
+
def get_audio(artifact_path: str, current_user: User = Depends(require_user)) -> FileResponse:
|
| 43 |
+
path = _owned_file(get_settings().resolved_tts_output_dir, artifact_path, current_user.id)
|
| 44 |
+
return _private_file_response(path)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.get("/videos/{artifact_path:path}")
|
| 48 |
+
def get_video(artifact_path: str, current_user: User = Depends(require_user)) -> FileResponse:
|
| 49 |
+
path = _owned_file(
|
| 50 |
+
get_settings().resolved_generated_video_output_dir, artifact_path, current_user.id
|
| 51 |
+
)
|
| 52 |
+
return _private_file_response(path)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.get("/learn-anything/{artifact_path:path}")
|
| 56 |
+
def get_lesson_artifact(
|
| 57 |
+
artifact_path: str, current_user: User = Depends(require_user)
|
| 58 |
+
) -> FileResponse:
|
| 59 |
+
path = _owned_file(ensure_public_root(), artifact_path, current_user.id)
|
| 60 |
+
return _private_file_response(path)
|
app/routes/health.py
CHANGED
|
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
|
|
| 7 |
|
| 8 |
from app.core.config import get_settings
|
| 9 |
from app.core.database import get_db
|
|
|
|
| 10 |
|
| 11 |
router = APIRouter()
|
| 12 |
|
|
@@ -26,6 +27,7 @@ def health_check() -> dict[str, str]:
|
|
| 26 |
def deep_health_check(
|
| 27 |
response: Response,
|
| 28 |
db: Session = Depends(get_db),
|
|
|
|
| 29 |
) -> dict[str, Any]:
|
| 30 |
settings = get_settings()
|
| 31 |
|
|
@@ -167,7 +169,7 @@ def deep_health_check(
|
|
| 167 |
|
| 168 |
|
| 169 |
@router.get("/health/ai")
|
| 170 |
-
def ai_health_check() -> dict[str, Any]:
|
| 171 |
from app.core.circuit_breaker import all_breaker_states
|
| 172 |
from app.core.prompt_cache import get_prompt_cache
|
| 173 |
|
|
|
|
| 7 |
|
| 8 |
from app.core.config import get_settings
|
| 9 |
from app.core.database import get_db
|
| 10 |
+
from app.core.admin_auth import require_admin
|
| 11 |
|
| 12 |
router = APIRouter()
|
| 13 |
|
|
|
|
| 27 |
def deep_health_check(
|
| 28 |
response: Response,
|
| 29 |
db: Session = Depends(get_db),
|
| 30 |
+
_admin=Depends(require_admin),
|
| 31 |
) -> dict[str, Any]:
|
| 32 |
settings = get_settings()
|
| 33 |
|
|
|
|
| 169 |
|
| 170 |
|
| 171 |
@router.get("/health/ai")
|
| 172 |
+
def ai_health_check(_admin=Depends(require_admin)) -> dict[str, Any]:
|
| 173 |
from app.core.circuit_breaker import all_breaker_states
|
| 174 |
from app.core.prompt_cache import get_prompt_cache
|
| 175 |
|
app/routes/learning_engine.py
CHANGED
|
@@ -193,6 +193,7 @@ def generate_learn_lesson(
|
|
| 193 |
level=payload.level,
|
| 194 |
medium=payload.medium,
|
| 195 |
context=source_context,
|
|
|
|
| 196 |
)
|
| 197 |
except LessonBuildError as exc:
|
| 198 |
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
|
|
|
| 193 |
level=payload.level,
|
| 194 |
medium=payload.medium,
|
| 195 |
context=source_context,
|
| 196 |
+
user_id=current_user.id,
|
| 197 |
)
|
| 198 |
except LessonBuildError as exc:
|
| 199 |
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
app/routes/transcription.py
CHANGED
|
@@ -1,9 +1,8 @@
|
|
| 1 |
"""Record Lecture transcription endpoint.
|
| 2 |
|
| 3 |
-
Accepts an uploaded audio clip and returns its transcript
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
unbounded speech-to-text cost. When Deepgram is not configured, or the clip has
|
| 7 |
no clear speech, the response says so honestly rather than failing or inventing
|
| 8 |
text. The transcript is returned to the client, which feeds it into the
|
| 9 |
existing note/quiz/flashcard generators, so nothing is stored server-side here.
|
|
@@ -14,8 +13,9 @@ from __future__ import annotations
|
|
| 14 |
import logging
|
| 15 |
|
| 16 |
from fastapi import APIRouter, Depends, File, UploadFile
|
|
|
|
| 17 |
|
| 18 |
-
from app.core.auth import
|
| 19 |
from app.core.config import get_settings
|
| 20 |
from app.models.user import User
|
| 21 |
from app.schemas.transcription import TranscriptionResponse
|
|
@@ -24,20 +24,37 @@ from app.services.transcription_service import transcribe_audio
|
|
| 24 |
logger = logging.getLogger(__name__)
|
| 25 |
router = APIRouter()
|
| 26 |
|
| 27 |
-
# ~25 MB. A few minutes of compressed lecture audio
|
| 28 |
-
#
|
| 29 |
MAX_AUDIO_BYTES = 25 * 1024 * 1024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
@router.post("/transcribe", response_model=TranscriptionResponse)
|
| 33 |
async def transcribe_lecture(
|
| 34 |
file: UploadFile = File(...),
|
| 35 |
-
current_user: User
|
| 36 |
) -> TranscriptionResponse:
|
| 37 |
settings = get_settings()
|
| 38 |
|
| 39 |
-
audio = await
|
| 40 |
-
|
|
|
|
| 41 |
return TranscriptionResponse(
|
| 42 |
ok=False,
|
| 43 |
configured=bool(settings.deepgram_api_key),
|
|
@@ -45,7 +62,8 @@ async def transcribe_lecture(
|
|
| 45 |
reason="That recording is too long. Keep it under about 25 MB.",
|
| 46 |
)
|
| 47 |
|
| 48 |
-
result =
|
|
|
|
| 49 |
audio,
|
| 50 |
api_key=settings.deepgram_api_key,
|
| 51 |
content_type=file.content_type,
|
|
|
|
| 1 |
"""Record Lecture transcription endpoint.
|
| 2 |
|
| 3 |
+
Accepts an authenticated user's uploaded audio clip and returns its transcript
|
| 4 |
+
via Deepgram. The clip size is capped before it is buffered so this endpoint
|
| 5 |
+
cannot run up unbounded memory or speech-to-text cost. When Deepgram is not configured, or the clip has
|
|
|
|
| 6 |
no clear speech, the response says so honestly rather than failing or inventing
|
| 7 |
text. The transcript is returned to the client, which feeds it into the
|
| 8 |
existing note/quiz/flashcard generators, so nothing is stored server-side here.
|
|
|
|
| 13 |
import logging
|
| 14 |
|
| 15 |
from fastapi import APIRouter, Depends, File, UploadFile
|
| 16 |
+
from starlette.concurrency import run_in_threadpool
|
| 17 |
|
| 18 |
+
from app.core.auth import require_user
|
| 19 |
from app.core.config import get_settings
|
| 20 |
from app.models.user import User
|
| 21 |
from app.schemas.transcription import TranscriptionResponse
|
|
|
|
| 24 |
logger = logging.getLogger(__name__)
|
| 25 |
router = APIRouter()
|
| 26 |
|
| 27 |
+
# ~25 MB. A few minutes of compressed lecture audio without unbounded request
|
| 28 |
+
# memory or speech-to-text spend.
|
| 29 |
MAX_AUDIO_BYTES = 25 * 1024 * 1024
|
| 30 |
+
_READ_CHUNK_BYTES = 1024 * 1024
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def _read_audio_with_limit(file: UploadFile) -> bytes | None:
|
| 34 |
+
"""Bound application memory before joining an upload into bytes."""
|
| 35 |
+
chunks: list[bytes] = []
|
| 36 |
+
total = 0
|
| 37 |
+
while True:
|
| 38 |
+
chunk = await file.read(_READ_CHUNK_BYTES)
|
| 39 |
+
if not chunk:
|
| 40 |
+
break
|
| 41 |
+
total += len(chunk)
|
| 42 |
+
if total > MAX_AUDIO_BYTES:
|
| 43 |
+
return None
|
| 44 |
+
chunks.append(chunk)
|
| 45 |
+
return b"".join(chunks)
|
| 46 |
|
| 47 |
|
| 48 |
@router.post("/transcribe", response_model=TranscriptionResponse)
|
| 49 |
async def transcribe_lecture(
|
| 50 |
file: UploadFile = File(...),
|
| 51 |
+
current_user: User = Depends(require_user),
|
| 52 |
) -> TranscriptionResponse:
|
| 53 |
settings = get_settings()
|
| 54 |
|
| 55 |
+
audio = await _read_audio_with_limit(file)
|
| 56 |
+
await file.close()
|
| 57 |
+
if audio is None:
|
| 58 |
return TranscriptionResponse(
|
| 59 |
ok=False,
|
| 60 |
configured=bool(settings.deepgram_api_key),
|
|
|
|
| 62 |
reason="That recording is too long. Keep it under about 25 MB.",
|
| 63 |
)
|
| 64 |
|
| 65 |
+
result = await run_in_threadpool(
|
| 66 |
+
transcribe_audio,
|
| 67 |
audio,
|
| 68 |
api_key=settings.deepgram_api_key,
|
| 69 |
content_type=file.content_type,
|
app/routes/video.py
CHANGED
|
@@ -328,12 +328,11 @@ def render_final_video_route(
|
|
| 328 |
payload: RenderFinalVideoRequest,
|
| 329 |
current_user: User = Depends(require_user),
|
| 330 |
) -> dict[str, Any]:
|
| 331 |
-
_ = current_user
|
| 332 |
try:
|
| 333 |
if get_settings().environment == "production" and payload.allow_mock_audio:
|
| 334 |
raise VideoValidationError("Mock audio rendering is disabled in production.")
|
| 335 |
enforce_video_beta_limits(payload.scene_plan)
|
| 336 |
-
return render_final_video(payload)
|
| 337 |
except VideoValidationError as exc:
|
| 338 |
# Audio-file validation messages can contain local file paths — sanitize.
|
| 339 |
student_safe_error(
|
|
|
|
| 328 |
payload: RenderFinalVideoRequest,
|
| 329 |
current_user: User = Depends(require_user),
|
| 330 |
) -> dict[str, Any]:
|
|
|
|
| 331 |
try:
|
| 332 |
if get_settings().environment == "production" and payload.allow_mock_audio:
|
| 333 |
raise VideoValidationError("Mock audio rendering is disabled in production.")
|
| 334 |
enforce_video_beta_limits(payload.scene_plan)
|
| 335 |
+
return render_final_video(payload, user_id=current_user.id)
|
| 336 |
except VideoValidationError as exc:
|
| 337 |
# Audio-file validation messages can contain local file paths — sanitize.
|
| 338 |
student_safe_error(
|
app/schemas/source.py
CHANGED
|
@@ -16,22 +16,26 @@ SourceType = Literal[
|
|
| 16 |
"link",
|
| 17 |
]
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
class SourceTextCreate(BaseModel):
|
| 21 |
-
title: str
|
| 22 |
-
text: str
|
| 23 |
source_type: SourceType = "syllabus_text"
|
| 24 |
-
subject: str | None = None
|
| 25 |
-
chapter: str | None = None
|
| 26 |
-
syllabus: str | None = None
|
| 27 |
|
| 28 |
|
| 29 |
class SourceUpdate(BaseModel):
|
| 30 |
-
title: str | None = None
|
| 31 |
-
text: str | None = None
|
| 32 |
-
subject: str | None = None
|
| 33 |
-
chapter: str | None = None
|
| 34 |
-
syllabus: str | None = None
|
| 35 |
|
| 36 |
|
| 37 |
class SourceRead(BaseModel):
|
|
|
|
| 16 |
"link",
|
| 17 |
]
|
| 18 |
|
| 19 |
+
MAX_SOURCE_TITLE_CHARS = 240
|
| 20 |
+
MAX_SOURCE_TEXT_CHARS = 200_000
|
| 21 |
+
MAX_SOURCE_METADATA_CHARS = 120
|
| 22 |
+
|
| 23 |
|
| 24 |
class SourceTextCreate(BaseModel):
|
| 25 |
+
title: str = Field(min_length=1, max_length=MAX_SOURCE_TITLE_CHARS)
|
| 26 |
+
text: str = Field(min_length=1, max_length=MAX_SOURCE_TEXT_CHARS)
|
| 27 |
source_type: SourceType = "syllabus_text"
|
| 28 |
+
subject: str | None = Field(default=None, max_length=MAX_SOURCE_METADATA_CHARS)
|
| 29 |
+
chapter: str | None = Field(default=None, max_length=MAX_SOURCE_METADATA_CHARS)
|
| 30 |
+
syllabus: str | None = Field(default=None, max_length=MAX_SOURCE_METADATA_CHARS)
|
| 31 |
|
| 32 |
|
| 33 |
class SourceUpdate(BaseModel):
|
| 34 |
+
title: str | None = Field(default=None, min_length=1, max_length=MAX_SOURCE_TITLE_CHARS)
|
| 35 |
+
text: str | None = Field(default=None, min_length=1, max_length=MAX_SOURCE_TEXT_CHARS)
|
| 36 |
+
subject: str | None = Field(default=None, max_length=MAX_SOURCE_METADATA_CHARS)
|
| 37 |
+
chapter: str | None = Field(default=None, max_length=MAX_SOURCE_METADATA_CHARS)
|
| 38 |
+
syllabus: str | None = Field(default=None, max_length=MAX_SOURCE_METADATA_CHARS)
|
| 39 |
|
| 40 |
|
| 41 |
class SourceRead(BaseModel):
|
app/schemas/video.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from datetime import datetime
|
| 2 |
import re
|
| 3 |
-
from typing import Any, Literal
|
| 4 |
|
| 5 |
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
|
| 6 |
|
|
@@ -11,76 +11,87 @@ VideoFormat = Literal["16:9", "9:16"]
|
|
| 11 |
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
|
| 12 |
SceneAudioState = Literal["pending", "generating", "ready", "generated", "failed", "skipped"]
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
class VideoVoiceSegment(BaseModel):
|
| 16 |
lang: Literal["ml", "en"]
|
| 17 |
-
text: str
|
| 18 |
|
| 19 |
|
| 20 |
class VideoSubtitleSegment(BaseModel):
|
| 21 |
start: float = Field(ge=0)
|
| 22 |
end: float = Field(gt=0)
|
| 23 |
-
text: str
|
| 24 |
|
| 25 |
|
| 26 |
class VideoVisualElement(BaseModel):
|
| 27 |
-
type:
|
| 28 |
-
name:
|
| 29 |
-
label:
|
| 30 |
-
position:
|
| 31 |
-
animation:
|
| 32 |
|
| 33 |
-
model_config = {"extra": "
|
| 34 |
|
| 35 |
|
| 36 |
class VideoSceneAudioInput(BaseModel):
|
| 37 |
scene_id: int | None = None
|
| 38 |
id: int | None = None
|
| 39 |
-
type:
|
| 40 |
-
duration_seconds: float = Field(default=6, ge=0.5)
|
| 41 |
-
screen_text: str | None = None
|
| 42 |
-
text: str | None = None
|
| 43 |
-
voice_text: str | None = None
|
| 44 |
-
subtitle_text: str | None = None
|
| 45 |
-
subtitle_segments: list[VideoSubtitleSegment] = Field(default_factory=list)
|
| 46 |
-
keywords: list[
|
| 47 |
-
visual_hint:
|
| 48 |
-
visual_elements: list[VideoVisualElement] = Field(default_factory=list)
|
| 49 |
-
mascot_expression:
|
| 50 |
-
transition:
|
| 51 |
-
purpose:
|
| 52 |
-
learning_purpose:
|
| 53 |
-
audio_src: str | None = None
|
| 54 |
is_mock_audio: bool | None = None
|
| 55 |
audio_kind: str | None = None
|
| 56 |
audio_file_size: int | None = None
|
| 57 |
audio_duration_verified: bool | None = None
|
| 58 |
-
segments: list[VideoVoiceSegment] | None = None
|
| 59 |
|
| 60 |
-
model_config = {"extra": "
|
| 61 |
|
| 62 |
|
| 63 |
class VideoScenePlanAudioInput(BaseModel):
|
| 64 |
-
title: str = "Untitled explainer"
|
| 65 |
-
duration_minutes: float = Field(default=1, ge=0.1)
|
| 66 |
-
total_duration_seconds: float | None = None
|
| 67 |
-
language:
|
| 68 |
-
style:
|
| 69 |
visual_style: VisualStyle = "clean_explainer"
|
| 70 |
-
video_format:
|
| 71 |
voice_mode: VoiceMode | None = None
|
| 72 |
-
scenes: list[VideoSceneAudioInput] = Field(default_factory=list)
|
| 73 |
|
| 74 |
-
model_config = {"extra": "
|
| 75 |
|
| 76 |
|
| 77 |
class GenerateAudioRequest(BaseModel):
|
| 78 |
scene_plan: VideoScenePlanAudioInput
|
| 79 |
voice_mode: VoiceMode = "english_soft"
|
| 80 |
-
voice:
|
| 81 |
-
language:
|
| 82 |
-
languages: list[
|
| 83 |
-
provider:
|
| 84 |
|
| 85 |
|
| 86 |
class SceneAudioStatus(BaseModel):
|
|
@@ -129,16 +140,16 @@ class GenerateAudioResponse(BaseModel):
|
|
| 129 |
class RenderFinalVideoRequest(BaseModel):
|
| 130 |
scene_plan: VideoScenePlanAudioInput
|
| 131 |
audio_result: GenerateAudioResponse | None = None
|
| 132 |
-
title: str | None = None
|
| 133 |
allow_mock_audio: bool = False
|
| 134 |
|
| 135 |
|
| 136 |
class VideoRenderRequest(RenderFinalVideoRequest):
|
| 137 |
-
user_id: str | None = None
|
| 138 |
voice_mode: VoiceMode | None = None
|
| 139 |
-
voice:
|
| 140 |
-
language:
|
| 141 |
-
provider:
|
| 142 |
|
| 143 |
|
| 144 |
class RenderFinalVideoResponse(BaseModel):
|
|
|
|
| 1 |
from datetime import datetime
|
| 2 |
import re
|
| 3 |
+
from typing import Annotated, Any, Literal
|
| 4 |
|
| 5 |
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
|
| 6 |
|
|
|
|
| 11 |
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
|
| 12 |
SceneAudioState = Literal["pending", "generating", "ready", "generated", "failed", "skipped"]
|
| 13 |
|
| 14 |
+
MAX_VIDEO_SCENES = 40
|
| 15 |
+
MAX_VIDEO_LANGUAGES = 3
|
| 16 |
+
MAX_SCENE_SEGMENTS = 80
|
| 17 |
+
MAX_SCENE_VISUALS = 50
|
| 18 |
+
MAX_SCENE_KEYWORDS = 40
|
| 19 |
+
MAX_VIDEO_TEXT_CHARS = 700
|
| 20 |
+
MAX_VIDEO_LABEL_CHARS = 120
|
| 21 |
+
MAX_VIDEO_LANGUAGE_CHARS = 32
|
| 22 |
+
VideoShortText = Annotated[str, Field(max_length=MAX_VIDEO_LABEL_CHARS)]
|
| 23 |
+
VideoLanguage = Annotated[str, Field(max_length=MAX_VIDEO_LANGUAGE_CHARS)]
|
| 24 |
+
|
| 25 |
|
| 26 |
class VideoVoiceSegment(BaseModel):
|
| 27 |
lang: Literal["ml", "en"]
|
| 28 |
+
text: str = Field(min_length=1, max_length=MAX_VIDEO_TEXT_CHARS)
|
| 29 |
|
| 30 |
|
| 31 |
class VideoSubtitleSegment(BaseModel):
|
| 32 |
start: float = Field(ge=0)
|
| 33 |
end: float = Field(gt=0)
|
| 34 |
+
text: str = Field(min_length=1, max_length=MAX_VIDEO_TEXT_CHARS)
|
| 35 |
|
| 36 |
|
| 37 |
class VideoVisualElement(BaseModel):
|
| 38 |
+
type: VideoShortText = "icon"
|
| 39 |
+
name: VideoShortText
|
| 40 |
+
label: VideoShortText | None = None
|
| 41 |
+
position: VideoShortText | None = None
|
| 42 |
+
animation: VideoShortText | None = None
|
| 43 |
|
| 44 |
+
model_config = {"extra": "ignore"}
|
| 45 |
|
| 46 |
|
| 47 |
class VideoSceneAudioInput(BaseModel):
|
| 48 |
scene_id: int | None = None
|
| 49 |
id: int | None = None
|
| 50 |
+
type: VideoShortText = "meaning"
|
| 51 |
+
duration_seconds: float = Field(default=6, ge=0.5, le=600)
|
| 52 |
+
screen_text: str | None = Field(default=None, max_length=MAX_VIDEO_TEXT_CHARS)
|
| 53 |
+
text: str | None = Field(default=None, max_length=MAX_VIDEO_TEXT_CHARS)
|
| 54 |
+
voice_text: str | None = Field(default=None, max_length=MAX_VIDEO_TEXT_CHARS)
|
| 55 |
+
subtitle_text: str | None = Field(default=None, max_length=MAX_VIDEO_TEXT_CHARS)
|
| 56 |
+
subtitle_segments: list[VideoSubtitleSegment] = Field(default_factory=list, max_length=MAX_SCENE_SEGMENTS)
|
| 57 |
+
keywords: list[VideoShortText] = Field(default_factory=list, max_length=MAX_SCENE_KEYWORDS)
|
| 58 |
+
visual_hint: VideoShortText | None = None
|
| 59 |
+
visual_elements: list[VideoVisualElement] = Field(default_factory=list, max_length=MAX_SCENE_VISUALS)
|
| 60 |
+
mascot_expression: VideoShortText | None = None
|
| 61 |
+
transition: VideoShortText | None = None
|
| 62 |
+
purpose: VideoShortText | None = None
|
| 63 |
+
learning_purpose: VideoShortText | None = None
|
| 64 |
+
audio_src: str | None = Field(default=None, max_length=1_000)
|
| 65 |
is_mock_audio: bool | None = None
|
| 66 |
audio_kind: str | None = None
|
| 67 |
audio_file_size: int | None = None
|
| 68 |
audio_duration_verified: bool | None = None
|
| 69 |
+
segments: list[VideoVoiceSegment] | None = Field(default=None, max_length=MAX_SCENE_SEGMENTS)
|
| 70 |
|
| 71 |
+
model_config = {"extra": "ignore"}
|
| 72 |
|
| 73 |
|
| 74 |
class VideoScenePlanAudioInput(BaseModel):
|
| 75 |
+
title: str = Field(default="Untitled explainer", max_length=240)
|
| 76 |
+
duration_minutes: float = Field(default=1, ge=0.1, le=60)
|
| 77 |
+
total_duration_seconds: float | None = Field(default=None, ge=0.1, le=3_600)
|
| 78 |
+
language: VideoLanguage = "English"
|
| 79 |
+
style: VideoShortText = "clean_explainer"
|
| 80 |
visual_style: VisualStyle = "clean_explainer"
|
| 81 |
+
video_format: VideoShortText = "16:9"
|
| 82 |
voice_mode: VoiceMode | None = None
|
| 83 |
+
scenes: list[VideoSceneAudioInput] = Field(default_factory=list, max_length=MAX_VIDEO_SCENES)
|
| 84 |
|
| 85 |
+
model_config = {"extra": "ignore"}
|
| 86 |
|
| 87 |
|
| 88 |
class GenerateAudioRequest(BaseModel):
|
| 89 |
scene_plan: VideoScenePlanAudioInput
|
| 90 |
voice_mode: VoiceMode = "english_soft"
|
| 91 |
+
voice: VideoShortText = "nila"
|
| 92 |
+
language: VideoLanguage = "English"
|
| 93 |
+
languages: list[VideoLanguage] | None = Field(default=None, max_length=MAX_VIDEO_LANGUAGES)
|
| 94 |
+
provider: VideoShortText | None = None
|
| 95 |
|
| 96 |
|
| 97 |
class SceneAudioStatus(BaseModel):
|
|
|
|
| 140 |
class RenderFinalVideoRequest(BaseModel):
|
| 141 |
scene_plan: VideoScenePlanAudioInput
|
| 142 |
audio_result: GenerateAudioResponse | None = None
|
| 143 |
+
title: str | None = Field(default=None, max_length=240)
|
| 144 |
allow_mock_audio: bool = False
|
| 145 |
|
| 146 |
|
| 147 |
class VideoRenderRequest(RenderFinalVideoRequest):
|
| 148 |
+
user_id: str | None = Field(default=None, max_length=128)
|
| 149 |
voice_mode: VoiceMode | None = None
|
| 150 |
+
voice: VideoShortText = "nila"
|
| 151 |
+
language: VideoLanguage | None = None
|
| 152 |
+
provider: VideoShortText | None = None
|
| 153 |
|
| 154 |
|
| 155 |
class RenderFinalVideoResponse(BaseModel):
|
app/services/account_deletion.py
CHANGED
|
@@ -48,6 +48,7 @@ class _OwnedArtifacts:
|
|
| 48 |
document_ids: set[str]
|
| 49 |
paper_ids: set[str]
|
| 50 |
local_paths: set[str]
|
|
|
|
| 51 |
job_ids: set[str]
|
| 52 |
storage_objects: set[tuple[str, str]]
|
| 53 |
cache_keys: set[str]
|
|
@@ -219,6 +220,11 @@ def _collect_owned_artifacts(db: Session, user_id: str) -> _OwnedArtifacts:
|
|
| 219 |
*(row.file_path for row in papers if row.file_path),
|
| 220 |
*(row.output_file_path for row in video_jobs if row.output_file_path),
|
| 221 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
storage_objects: set[tuple[str, str]] = set()
|
| 223 |
for job in video_jobs:
|
| 224 |
if job.output_object_key:
|
|
@@ -244,6 +250,7 @@ def _collect_owned_artifacts(db: Session, user_id: str) -> _OwnedArtifacts:
|
|
| 244 |
document_ids=document_ids,
|
| 245 |
paper_ids=paper_ids,
|
| 246 |
local_paths=local_paths,
|
|
|
|
| 247 |
job_ids={row.id for row in video_jobs},
|
| 248 |
storage_objects=storage_objects,
|
| 249 |
cache_keys=cache_keys,
|
|
@@ -359,6 +366,8 @@ def _delete_local_artifacts(artifacts: _OwnedArtifacts) -> None:
|
|
| 359 |
)
|
| 360 |
for raw_path in artifacts.local_paths:
|
| 361 |
_delete_safe_local_path(raw_path, allowed_roots)
|
|
|
|
|
|
|
| 362 |
jobs_root = settings.resolved_generated_video_jobs_dir.resolve()
|
| 363 |
for job_id in artifacts.job_ids:
|
| 364 |
_delete_safe_local_path(str(jobs_root / job_id), allowed_roots, directory=True)
|
|
@@ -374,6 +383,8 @@ def _validate_local_artifacts(artifacts: _OwnedArtifacts) -> None:
|
|
| 374 |
)
|
| 375 |
for raw_path in artifacts.local_paths:
|
| 376 |
_managed_existing_path(raw_path, allowed_roots)
|
|
|
|
|
|
|
| 377 |
jobs_root = settings.resolved_generated_video_jobs_dir.resolve()
|
| 378 |
for job_id in artifacts.job_ids:
|
| 379 |
_managed_existing_path(str(jobs_root / job_id), allowed_roots)
|
|
|
|
| 48 |
document_ids: set[str]
|
| 49 |
paper_ids: set[str]
|
| 50 |
local_paths: set[str]
|
| 51 |
+
local_directories: set[str]
|
| 52 |
job_ids: set[str]
|
| 53 |
storage_objects: set[tuple[str, str]]
|
| 54 |
cache_keys: set[str]
|
|
|
|
| 220 |
*(row.file_path for row in papers if row.file_path),
|
| 221 |
*(row.output_file_path for row in video_jobs if row.output_file_path),
|
| 222 |
}
|
| 223 |
+
settings = get_settings()
|
| 224 |
+
local_directories = {
|
| 225 |
+
str(settings.resolved_tts_output_dir / user_id),
|
| 226 |
+
str(settings.resolved_generated_video_output_dir / user_id),
|
| 227 |
+
}
|
| 228 |
storage_objects: set[tuple[str, str]] = set()
|
| 229 |
for job in video_jobs:
|
| 230 |
if job.output_object_key:
|
|
|
|
| 250 |
document_ids=document_ids,
|
| 251 |
paper_ids=paper_ids,
|
| 252 |
local_paths=local_paths,
|
| 253 |
+
local_directories=local_directories,
|
| 254 |
job_ids={row.id for row in video_jobs},
|
| 255 |
storage_objects=storage_objects,
|
| 256 |
cache_keys=cache_keys,
|
|
|
|
| 366 |
)
|
| 367 |
for raw_path in artifacts.local_paths:
|
| 368 |
_delete_safe_local_path(raw_path, allowed_roots)
|
| 369 |
+
for raw_path in artifacts.local_directories:
|
| 370 |
+
_delete_safe_local_path(raw_path, allowed_roots, directory=True)
|
| 371 |
jobs_root = settings.resolved_generated_video_jobs_dir.resolve()
|
| 372 |
for job_id in artifacts.job_ids:
|
| 373 |
_delete_safe_local_path(str(jobs_root / job_id), allowed_roots, directory=True)
|
|
|
|
| 383 |
)
|
| 384 |
for raw_path in artifacts.local_paths:
|
| 385 |
_managed_existing_path(raw_path, allowed_roots)
|
| 386 |
+
for raw_path in artifacts.local_directories:
|
| 387 |
+
_managed_existing_path(raw_path, allowed_roots)
|
| 388 |
jobs_root = settings.resolved_generated_video_jobs_dir.resolve()
|
| 389 |
for job_id in artifacts.job_ids:
|
| 390 |
_managed_existing_path(str(jobs_root / job_id), allowed_roots)
|
app/services/learn_lesson_builder.py
CHANGED
|
@@ -7,10 +7,9 @@ with a board (heading + lines) to show while it is spoken, plus per-beat
|
|
| 7 |
audio. The student's browser "plays" it like a class — audio + synced board —
|
| 8 |
so there is no video render cost at all.
|
| 9 |
|
| 10 |
-
Cost control:
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
for free. Popular lessons become effectively pre-made.
|
| 14 |
|
| 15 |
Voice: Deepgram Aura for English / English-medium ("Manglish") lessons. A
|
| 16 |
Malayalam-medium lesson routes to the local AI4Bharat provider instead
|
|
@@ -63,8 +62,8 @@ DEFAULT_LESSON_LLM_BUDGET_SECONDS = 35.0
|
|
| 63 |
DEFAULT_LESSON_AUDIO_BUDGET_SECONDS = 20.0
|
| 64 |
DEFAULT_LESSON_TTS_TIMEOUT_SECONDS = 15.0
|
| 65 |
|
| 66 |
-
# Prevent
|
| 67 |
-
#
|
| 68 |
_lesson_build_locks: dict[str, threading.Lock] = {}
|
| 69 |
_lesson_build_locks_guard = threading.Lock()
|
| 70 |
|
|
@@ -137,7 +136,14 @@ def _env(name: str) -> str:
|
|
| 137 |
|
| 138 |
|
| 139 |
def lesson_hash(
|
| 140 |
-
topic: str,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
) -> str:
|
| 142 |
payload = json.dumps(
|
| 143 |
{
|
|
@@ -146,7 +152,9 @@ def lesson_hash(
|
|
| 146 |
"level": level.strip().lower(),
|
| 147 |
"medium": medium.strip().lower(),
|
| 148 |
"voice": voice.strip().lower(),
|
| 149 |
-
"
|
|
|
|
|
|
|
| 150 |
},
|
| 151 |
sort_keys=True,
|
| 152 |
)
|
|
@@ -852,7 +860,9 @@ def synthesize_beats(
|
|
| 852 |
board_heading=str(beat.get("board_heading", "")),
|
| 853 |
board_lines=[str(line) for line in (beat.get("board_lines") or [])],
|
| 854 |
visual_hint=str(beat.get("visual_hint", "")),
|
| 855 |
-
audio_src=
|
|
|
|
|
|
|
| 856 |
start_second=round(cursor, 3),
|
| 857 |
duration_seconds=duration,
|
| 858 |
)
|
|
@@ -959,12 +969,12 @@ def build_lesson(
|
|
| 959 |
level: str = "",
|
| 960 |
medium: str = "english",
|
| 961 |
context: str = "",
|
|
|
|
| 962 |
force: bool = False,
|
| 963 |
) -> dict[str, Any]:
|
| 964 |
-
"""Full pipeline: script -> audio -> playable manifest, cached
|
| 965 |
|
| 966 |
-
|
| 967 |
-
the next 999+ hits serve the same lesson.json + audio instantly.
|
| 968 |
Concurrent first opens for the same hash serialize on a per-hash lock so
|
| 969 |
we do not fan out N identical LLM bills under load.
|
| 970 |
"""
|
|
@@ -977,9 +987,10 @@ def build_lesson(
|
|
| 977 |
if medium.strip().lower() in {"malayalam", "ml"}
|
| 978 |
else (_env("DOCDOE_TTS_MODEL") or "aura-luna-en")
|
| 979 |
)
|
| 980 |
-
|
|
|
|
| 981 |
cache_root = ensure_public_root()
|
| 982 |
-
out_dir = cache_root / key
|
| 983 |
manifest_path = out_dir / "lesson.json"
|
| 984 |
|
| 985 |
if manifest_path.exists() and not force:
|
|
@@ -1005,26 +1016,12 @@ def build_lesson(
|
|
| 1005 |
try:
|
| 1006 |
out_dir.mkdir(parents=True, exist_ok=True)
|
| 1007 |
except OSError as exc:
|
| 1008 |
-
#
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
|
| 1012 |
-
|
| 1013 |
-
)
|
| 1014 |
-
try:
|
| 1015 |
-
emergency.mkdir(parents=True, exist_ok=True)
|
| 1016 |
-
out_dir = emergency
|
| 1017 |
-
manifest_path = out_dir / "lesson.json"
|
| 1018 |
-
logger.warning(
|
| 1019 |
-
"Lesson cache mkdir failed (%s); using emergency path %s",
|
| 1020 |
-
exc,
|
| 1021 |
-
out_dir,
|
| 1022 |
-
)
|
| 1023 |
-
except OSError as exc2:
|
| 1024 |
-
raise LessonBuildError(
|
| 1025 |
-
f"Could not create lesson cache folder ({out_dir}): {exc}; "
|
| 1026 |
-
f"emergency also failed: {exc2}"
|
| 1027 |
-
) from exc2
|
| 1028 |
delivery_mode = "reading" if mock_mode else "audio"
|
| 1029 |
delivery_notice = (
|
| 1030 |
"Local preview: this complete starter class is available in reading mode; no generated voice is being presented as real audio."
|
|
|
|
| 7 |
audio. The student's browser "plays" it like a class — audio + synced board —
|
| 8 |
so there is no video render cost at all.
|
| 9 |
|
| 10 |
+
Cost control: lessons are keyed by their content, source context, and owner.
|
| 11 |
+
Sensitive uploads therefore cannot select another student's cached manifest or
|
| 12 |
+
audio.
|
|
|
|
| 13 |
|
| 14 |
Voice: Deepgram Aura for English / English-medium ("Manglish") lessons. A
|
| 15 |
Malayalam-medium lesson routes to the local AI4Bharat provider instead
|
|
|
|
| 62 |
DEFAULT_LESSON_AUDIO_BUDGET_SECONDS = 20.0
|
| 63 |
DEFAULT_LESSON_TTS_TIMEOUT_SECONDS = 15.0
|
| 64 |
|
| 65 |
+
# Prevent a user from opening the same uncached lesson repeatedly and fanning
|
| 66 |
+
# out parallel authoring jobs.
|
| 67 |
_lesson_build_locks: dict[str, threading.Lock] = {}
|
| 68 |
_lesson_build_locks_guard = threading.Lock()
|
| 69 |
|
|
|
|
| 136 |
|
| 137 |
|
| 138 |
def lesson_hash(
|
| 139 |
+
topic: str,
|
| 140 |
+
lesson_title: str,
|
| 141 |
+
level: str,
|
| 142 |
+
medium: str,
|
| 143 |
+
voice: str,
|
| 144 |
+
*,
|
| 145 |
+
context: str = "",
|
| 146 |
+
user_id: str = "",
|
| 147 |
) -> str:
|
| 148 |
payload = json.dumps(
|
| 149 |
{
|
|
|
|
| 152 |
"level": level.strip().lower(),
|
| 153 |
"medium": medium.strip().lower(),
|
| 154 |
"voice": voice.strip().lower(),
|
| 155 |
+
"context": hashlib.sha256(context.encode("utf-8")).hexdigest(),
|
| 156 |
+
"user": user_id,
|
| 157 |
+
"schema": "learn-lesson-v2-private",
|
| 158 |
},
|
| 159 |
sort_keys=True,
|
| 160 |
)
|
|
|
|
| 860 |
board_heading=str(beat.get("board_heading", "")),
|
| 861 |
board_lines=[str(line) for line in (beat.get("board_lines") or [])],
|
| 862 |
visual_hint=str(beat.get("visual_hint", "")),
|
| 863 |
+
audio_src=(
|
| 864 |
+
f"/generated/learn-anything/{out_dir.parent.name}/{out_dir.name}/{audio_path.name}"
|
| 865 |
+
),
|
| 866 |
start_second=round(cursor, 3),
|
| 867 |
duration_seconds=duration,
|
| 868 |
)
|
|
|
|
| 969 |
level: str = "",
|
| 970 |
medium: str = "english",
|
| 971 |
context: str = "",
|
| 972 |
+
user_id: str | None = None,
|
| 973 |
force: bool = False,
|
| 974 |
) -> dict[str, Any]:
|
| 975 |
+
"""Full pipeline: script -> audio -> playable manifest, cached privately.
|
| 976 |
|
| 977 |
+
Every user's source context is isolated in its own cache namespace.
|
|
|
|
| 978 |
Concurrent first opens for the same hash serialize on a per-hash lock so
|
| 979 |
we do not fan out N identical LLM bills under load.
|
| 980 |
"""
|
|
|
|
| 987 |
if medium.strip().lower() in {"malayalam", "ml"}
|
| 988 |
else (_env("DOCDOE_TTS_MODEL") or "aura-luna-en")
|
| 989 |
)
|
| 990 |
+
owner = user_id or "unowned"
|
| 991 |
+
key = lesson_hash(topic, lesson_title, level, medium, voice, context=context, user_id=owner)
|
| 992 |
cache_root = ensure_public_root()
|
| 993 |
+
out_dir = cache_root / owner / key
|
| 994 |
manifest_path = out_dir / "lesson.json"
|
| 995 |
|
| 996 |
if manifest_path.exists() and not force:
|
|
|
|
| 1016 |
try:
|
| 1017 |
out_dir.mkdir(parents=True, exist_ok=True)
|
| 1018 |
except OSError as exc:
|
| 1019 |
+
# Do not write an untracked fallback that the authenticated media
|
| 1020 |
+
# router cannot authorize. `ensure_public_root` already tries the
|
| 1021 |
+
# configured cache, application cache, and an OS temp directory.
|
| 1022 |
+
raise LessonBuildError(
|
| 1023 |
+
f"Could not create private lesson cache folder ({out_dir}): {exc}"
|
| 1024 |
+
) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1025 |
delivery_mode = "reading" if mock_mode else "audio"
|
| 1026 |
delivery_notice = (
|
| 1027 |
"Local preview: this complete starter class is available in reading mode; no generated voice is being presented as real audio."
|
app/services/storage_provider.py
CHANGED
|
@@ -69,8 +69,24 @@ class LocalStorageProvider:
|
|
| 69 |
)
|
| 70 |
|
| 71 |
def delete_file(self, object_key: str) -> None:
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
def get_public_url(self, object_key: str) -> str:
|
| 76 |
key = _normalize_object_key(object_key)
|
|
|
|
| 69 |
)
|
| 70 |
|
| 71 |
def delete_file(self, object_key: str) -> None:
|
| 72 |
+
settings = get_settings()
|
| 73 |
+
key = _normalize_object_key(object_key)
|
| 74 |
+
roots = {
|
| 75 |
+
"generated-audio/": settings.resolved_tts_output_dir,
|
| 76 |
+
"generated-videos/": settings.resolved_generated_video_output_dir,
|
| 77 |
+
"uploads/": settings.resolved_upload_dir,
|
| 78 |
+
}
|
| 79 |
+
for prefix, root in roots.items():
|
| 80 |
+
if not key.startswith(prefix):
|
| 81 |
+
continue
|
| 82 |
+
candidate = (root / key.removeprefix(prefix)).resolve()
|
| 83 |
+
try:
|
| 84 |
+
candidate.relative_to(root.resolve())
|
| 85 |
+
except ValueError as exc:
|
| 86 |
+
raise StorageProviderError("Invalid local storage object key.") from exc
|
| 87 |
+
candidate.unlink(missing_ok=True)
|
| 88 |
+
return
|
| 89 |
+
raise StorageProviderError("Unsupported local storage object key.")
|
| 90 |
|
| 91 |
def get_public_url(self, object_key: str) -> str:
|
| 92 |
key = _normalize_object_key(object_key)
|
app/services/tts_provider.py
CHANGED
|
@@ -25,7 +25,6 @@ from app.services.audio_utils import (
|
|
| 25 |
normalize_audio_volume,
|
| 26 |
trim_silence_and_pad_audio,
|
| 27 |
)
|
| 28 |
-
from app.services.storage_provider import StorageProviderError, build_object_key, get_storage_provider
|
| 29 |
from app.services.video_validation import create_subtitle_segments
|
| 30 |
from app.utils.ids import prefixed_id
|
| 31 |
|
|
@@ -937,30 +936,11 @@ def _finalize_scene_audio_result(
|
|
| 937 |
synced_duration = round(max(detected_duration + 0.3, 1), 2)
|
| 938 |
audio_url = _audio_url_for_path(result.file_path, output_root)
|
| 939 |
storage_object = None
|
| 940 |
-
storage_provider = get_storage_provider()
|
| 941 |
requested_storage_provider = (settings.storage_provider or "local").strip().lower()
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
build_object_key(
|
| 946 |
-
settings.storage_audio_prefix,
|
| 947 |
-
user_id or "",
|
| 948 |
-
safe_video_id,
|
| 949 |
-
result.file_path.name,
|
| 950 |
-
),
|
| 951 |
)
|
| 952 |
-
if storage_object.provider != "local":
|
| 953 |
-
audio_url = storage_object.public_url
|
| 954 |
-
if requested_storage_provider in {"r2", "s3"} and storage_object.provider == "local":
|
| 955 |
-
result_warnings.append(
|
| 956 |
-
f"Cloud audio storage unavailable. Kept scene {scene_id} on local storage.",
|
| 957 |
-
)
|
| 958 |
-
except StorageProviderError as exc:
|
| 959 |
-
if requested_storage_provider in {"r2", "s3"}:
|
| 960 |
-
result_warnings.append(
|
| 961 |
-
f"Cloud audio upload failed for scene {scene_id}. Kept local audio URL.",
|
| 962 |
-
)
|
| 963 |
-
logger.warning("Audio storage upload failed: %s", _safe_error(exc))
|
| 964 |
|
| 965 |
updated_scene = scene.model_dump(exclude_none=True)
|
| 966 |
updated_scene["scene_id"] = scene_id
|
|
@@ -987,7 +967,7 @@ def _finalize_scene_audio_result(
|
|
| 987 |
"warning": " ".join(result_warnings) if result_warnings else None,
|
| 988 |
"storage_provider": storage_object.provider if storage_object else "local",
|
| 989 |
"object_key": storage_object.object_key if storage_object else None,
|
| 990 |
-
"public_url":
|
| 991 |
"size_bytes": storage_object.size_bytes if storage_object else result.file_path.stat().st_size,
|
| 992 |
"is_mock_audio": is_mock_audio,
|
| 993 |
"audio_kind": audio_kind,
|
|
|
|
| 25 |
normalize_audio_volume,
|
| 26 |
trim_silence_and_pad_audio,
|
| 27 |
)
|
|
|
|
| 28 |
from app.services.video_validation import create_subtitle_segments
|
| 29 |
from app.utils.ids import prefixed_id
|
| 30 |
|
|
|
|
| 936 |
synced_duration = round(max(detected_duration + 0.3, 1), 2)
|
| 937 |
audio_url = _audio_url_for_path(result.file_path, output_root)
|
| 938 |
storage_object = None
|
|
|
|
| 939 |
requested_storage_provider = (settings.storage_provider or "local").strip().lower()
|
| 940 |
+
if requested_storage_provider != "local":
|
| 941 |
+
result_warnings.append(
|
| 942 |
+
"External public media storage is disabled; the scene remains behind DocDoe authorization."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 943 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 944 |
|
| 945 |
updated_scene = scene.model_dump(exclude_none=True)
|
| 946 |
updated_scene["scene_id"] = scene_id
|
|
|
|
| 967 |
"warning": " ".join(result_warnings) if result_warnings else None,
|
| 968 |
"storage_provider": storage_object.provider if storage_object else "local",
|
| 969 |
"object_key": storage_object.object_key if storage_object else None,
|
| 970 |
+
"public_url": audio_url,
|
| 971 |
"size_bytes": storage_object.size_bytes if storage_object else result.file_path.stat().st_size,
|
| 972 |
"is_mock_audio": is_mock_audio,
|
| 973 |
"audio_kind": audio_kind,
|
app/services/video_jobs.py
CHANGED
|
@@ -178,6 +178,7 @@ def run_render_job(job_id: str, payload_data: dict[str, Any]) -> None:
|
|
| 178 |
allow_mock_audio=payload.allow_mock_audio,
|
| 179 |
),
|
| 180 |
video_id=job_id,
|
|
|
|
| 181 |
progress_callback=progress_callback,
|
| 182 |
)
|
| 183 |
_update_job(
|
|
|
|
| 178 |
allow_mock_audio=payload.allow_mock_audio,
|
| 179 |
),
|
| 180 |
video_id=job_id,
|
| 181 |
+
user_id=payload.user_id,
|
| 182 |
progress_callback=progress_callback,
|
| 183 |
)
|
| 184 |
_update_job(
|
app/services/video_renderer.py
CHANGED
|
@@ -13,7 +13,6 @@ from typing import Any
|
|
| 13 |
from app.core.config import PROJECT_ROOT, get_settings
|
| 14 |
from app.schemas.video import RenderFinalVideoRequest
|
| 15 |
from app.services.audio_utils import media_has_audio_stream, media_has_video_stream
|
| 16 |
-
from app.services.storage_provider import StorageProviderError, build_object_key, get_storage_provider
|
| 17 |
from app.services.video_validation import VideoValidationError, validate_audio_result_for_render
|
| 18 |
from app.utils.ids import prefixed_id
|
| 19 |
|
|
@@ -29,13 +28,14 @@ def render_final_video(
|
|
| 29 |
payload: RenderFinalVideoRequest,
|
| 30 |
*,
|
| 31 |
video_id: str | None = None,
|
|
|
|
| 32 |
progress_callback: Callable[[int, str], None] | None = None,
|
| 33 |
) -> dict[str, Any]:
|
| 34 |
settings = get_settings()
|
| 35 |
video_id = video_id or prefixed_id("video")
|
| 36 |
title = payload.title or payload.scene_plan.title or "Docdeo explainer"
|
| 37 |
slug = _slugify(title)
|
| 38 |
-
output_dir = settings.resolved_generated_video_output_dir
|
| 39 |
jobs_dir = settings.resolved_generated_video_jobs_dir
|
| 40 |
job_dir = jobs_dir / video_id
|
| 41 |
output_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -108,7 +108,6 @@ def render_final_video(
|
|
| 108 |
"render_provider": "remotion",
|
| 109 |
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 110 |
"warnings": warnings,
|
| 111 |
-
"job_dir": str(job_dir),
|
| 112 |
"audio_validation": audio_validation,
|
| 113 |
"output_validation": output_validation,
|
| 114 |
"audio_stream_verified": output_validation["audio_stream"],
|
|
@@ -116,33 +115,22 @@ def render_final_video(
|
|
| 116 |
|
| 117 |
_notify(progress_callback, 90, "uploading_or_saving")
|
| 118 |
storage_object = None
|
| 119 |
-
download_url = f"/generated/videos/{output_file.name}"
|
| 120 |
requested_storage_provider = (settings.storage_provider or "local").strip().lower()
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
output_file,
|
| 125 |
-
build_object_key(settings.storage_video_prefix, output_file.name),
|
| 126 |
-
content_type="video/mp4",
|
| 127 |
)
|
| 128 |
-
|
| 129 |
-
metadata["storage"] = storage_object.model_dump()
|
| 130 |
-
if requested_storage_provider in {"r2", "s3"} and storage_object.provider == "local":
|
| 131 |
-
warnings.append("Cloud storage unavailable. Kept final MP4 on local storage.")
|
| 132 |
-
except StorageProviderError as exc:
|
| 133 |
-
if requested_storage_provider in {"r2", "s3"}:
|
| 134 |
-
warnings.append("Cloud upload failed. Kept final MP4 on local storage.")
|
| 135 |
-
logger.warning("Video storage upload failed: %s", _safe_storage_error(exc))
|
| 136 |
-
metadata["storage"] = None
|
| 137 |
|
| 138 |
_notify(progress_callback, 92, "Saving render metadata")
|
| 139 |
metadata_file.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 140 |
|
| 141 |
-
metadata_url = f"/generated/videos/{metadata_file.name}"
|
| 142 |
return {
|
| 143 |
"video_id": video_id,
|
| 144 |
"status": "ready",
|
| 145 |
-
"file_path": f"/generated/videos/{output_file.name}",
|
| 146 |
"download_url": download_url,
|
| 147 |
"duration_seconds": duration_seconds,
|
| 148 |
"render_provider": "remotion",
|
|
@@ -150,7 +138,7 @@ def render_final_video(
|
|
| 150 |
"metadata_path": metadata_url,
|
| 151 |
"storage_provider": storage_object.provider if storage_object else "local",
|
| 152 |
"output_object_key": storage_object.object_key if storage_object else None,
|
| 153 |
-
"public_url":
|
| 154 |
"size_bytes": storage_object.size_bytes if storage_object else output_file.stat().st_size,
|
| 155 |
"validation": output_validation,
|
| 156 |
}
|
|
@@ -160,13 +148,14 @@ def render_study_video_preview(
|
|
| 160 |
payload: dict[str, Any],
|
| 161 |
*,
|
| 162 |
video_id: str | None = None,
|
|
|
|
| 163 |
progress_callback: Callable[[int, str], None] | None = None,
|
| 164 |
) -> dict[str, Any]:
|
| 165 |
settings = get_settings()
|
| 166 |
video_id = video_id or prefixed_id("video")
|
| 167 |
title = str(payload.get("title") or "DocDoe study video")
|
| 168 |
slug = _slugify(title)
|
| 169 |
-
output_dir = settings.resolved_generated_video_output_dir
|
| 170 |
jobs_dir = settings.resolved_generated_video_jobs_dir
|
| 171 |
job_dir = jobs_dir / video_id
|
| 172 |
output_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -230,40 +219,28 @@ def render_study_video_preview(
|
|
| 230 |
"preview_quality": True,
|
| 231 |
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 232 |
"warnings": warnings,
|
| 233 |
-
"job_dir": str(job_dir),
|
| 234 |
"output_validation": output_validation,
|
| 235 |
"audio_stream_verified": output_validation["audio_stream"],
|
| 236 |
}
|
| 237 |
|
| 238 |
_notify(progress_callback, 90, "saving_preview_video")
|
| 239 |
storage_object = None
|
| 240 |
-
download_url = f"/generated/videos/{output_file.name}"
|
| 241 |
requested_storage_provider = (settings.storage_provider or "local").strip().lower()
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
output_file,
|
| 246 |
-
build_object_key(settings.storage_video_prefix, output_file.name),
|
| 247 |
-
content_type="video/mp4",
|
| 248 |
)
|
| 249 |
-
|
| 250 |
-
metadata["storage"] = storage_object.model_dump()
|
| 251 |
-
if requested_storage_provider in {"r2", "s3"} and storage_object.provider == "local":
|
| 252 |
-
warnings.append("Cloud storage unavailable. Kept preview MP4 on local storage.")
|
| 253 |
-
except StorageProviderError as exc:
|
| 254 |
-
if requested_storage_provider in {"r2", "s3"}:
|
| 255 |
-
warnings.append("Cloud upload failed. Kept preview MP4 on local storage.")
|
| 256 |
-
logger.warning("Preview video storage upload failed: %s", _safe_storage_error(exc))
|
| 257 |
-
metadata["storage"] = None
|
| 258 |
|
| 259 |
metadata["warnings"] = warnings
|
| 260 |
metadata_file.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 261 |
|
| 262 |
-
metadata_url = f"/generated/videos/{metadata_file.name}"
|
| 263 |
return {
|
| 264 |
"video_id": video_id,
|
| 265 |
"status": "complete",
|
| 266 |
-
"file_path": f"/generated/videos/{output_file.name}",
|
| 267 |
"download_url": download_url,
|
| 268 |
"duration_seconds": duration_seconds,
|
| 269 |
"render_provider": "remotion",
|
|
@@ -271,7 +248,7 @@ def render_study_video_preview(
|
|
| 271 |
"metadata_path": metadata_url,
|
| 272 |
"storage_provider": storage_object.provider if storage_object else "local",
|
| 273 |
"output_object_key": storage_object.object_key if storage_object else None,
|
| 274 |
-
"public_url":
|
| 275 |
"size_bytes": storage_object.size_bytes if storage_object else output_file.stat().st_size,
|
| 276 |
"validation": output_validation,
|
| 277 |
}
|
|
|
|
| 13 |
from app.core.config import PROJECT_ROOT, get_settings
|
| 14 |
from app.schemas.video import RenderFinalVideoRequest
|
| 15 |
from app.services.audio_utils import media_has_audio_stream, media_has_video_stream
|
|
|
|
| 16 |
from app.services.video_validation import VideoValidationError, validate_audio_result_for_render
|
| 17 |
from app.utils.ids import prefixed_id
|
| 18 |
|
|
|
|
| 28 |
payload: RenderFinalVideoRequest,
|
| 29 |
*,
|
| 30 |
video_id: str | None = None,
|
| 31 |
+
user_id: str | None = None,
|
| 32 |
progress_callback: Callable[[int, str], None] | None = None,
|
| 33 |
) -> dict[str, Any]:
|
| 34 |
settings = get_settings()
|
| 35 |
video_id = video_id or prefixed_id("video")
|
| 36 |
title = payload.title or payload.scene_plan.title or "Docdeo explainer"
|
| 37 |
slug = _slugify(title)
|
| 38 |
+
output_dir = settings.resolved_generated_video_output_dir / (user_id or "unowned")
|
| 39 |
jobs_dir = settings.resolved_generated_video_jobs_dir
|
| 40 |
job_dir = jobs_dir / video_id
|
| 41 |
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 108 |
"render_provider": "remotion",
|
| 109 |
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 110 |
"warnings": warnings,
|
|
|
|
| 111 |
"audio_validation": audio_validation,
|
| 112 |
"output_validation": output_validation,
|
| 113 |
"audio_stream_verified": output_validation["audio_stream"],
|
|
|
|
| 115 |
|
| 116 |
_notify(progress_callback, 90, "uploading_or_saving")
|
| 117 |
storage_object = None
|
| 118 |
+
download_url = f"/generated/videos/{output_file.parent.name}/{output_file.name}"
|
| 119 |
requested_storage_provider = (settings.storage_provider or "local").strip().lower()
|
| 120 |
+
if requested_storage_provider != "local":
|
| 121 |
+
warnings.append(
|
| 122 |
+
"External public media storage is disabled; the MP4 remains behind DocDoe authorization."
|
|
|
|
|
|
|
|
|
|
| 123 |
)
|
| 124 |
+
metadata["storage"] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
_notify(progress_callback, 92, "Saving render metadata")
|
| 127 |
metadata_file.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 128 |
|
| 129 |
+
metadata_url = f"/generated/videos/{metadata_file.parent.name}/{metadata_file.name}"
|
| 130 |
return {
|
| 131 |
"video_id": video_id,
|
| 132 |
"status": "ready",
|
| 133 |
+
"file_path": f"/generated/videos/{output_file.parent.name}/{output_file.name}",
|
| 134 |
"download_url": download_url,
|
| 135 |
"duration_seconds": duration_seconds,
|
| 136 |
"render_provider": "remotion",
|
|
|
|
| 138 |
"metadata_path": metadata_url,
|
| 139 |
"storage_provider": storage_object.provider if storage_object else "local",
|
| 140 |
"output_object_key": storage_object.object_key if storage_object else None,
|
| 141 |
+
"public_url": download_url,
|
| 142 |
"size_bytes": storage_object.size_bytes if storage_object else output_file.stat().st_size,
|
| 143 |
"validation": output_validation,
|
| 144 |
}
|
|
|
|
| 148 |
payload: dict[str, Any],
|
| 149 |
*,
|
| 150 |
video_id: str | None = None,
|
| 151 |
+
user_id: str | None = None,
|
| 152 |
progress_callback: Callable[[int, str], None] | None = None,
|
| 153 |
) -> dict[str, Any]:
|
| 154 |
settings = get_settings()
|
| 155 |
video_id = video_id or prefixed_id("video")
|
| 156 |
title = str(payload.get("title") or "DocDoe study video")
|
| 157 |
slug = _slugify(title)
|
| 158 |
+
output_dir = settings.resolved_generated_video_output_dir / (user_id or "unowned")
|
| 159 |
jobs_dir = settings.resolved_generated_video_jobs_dir
|
| 160 |
job_dir = jobs_dir / video_id
|
| 161 |
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 219 |
"preview_quality": True,
|
| 220 |
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 221 |
"warnings": warnings,
|
|
|
|
| 222 |
"output_validation": output_validation,
|
| 223 |
"audio_stream_verified": output_validation["audio_stream"],
|
| 224 |
}
|
| 225 |
|
| 226 |
_notify(progress_callback, 90, "saving_preview_video")
|
| 227 |
storage_object = None
|
| 228 |
+
download_url = f"/generated/videos/{output_file.parent.name}/{output_file.name}"
|
| 229 |
requested_storage_provider = (settings.storage_provider or "local").strip().lower()
|
| 230 |
+
if requested_storage_provider != "local":
|
| 231 |
+
warnings.append(
|
| 232 |
+
"External public media storage is disabled; the preview remains behind DocDoe authorization."
|
|
|
|
|
|
|
|
|
|
| 233 |
)
|
| 234 |
+
metadata["storage"] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
metadata["warnings"] = warnings
|
| 237 |
metadata_file.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 238 |
|
| 239 |
+
metadata_url = f"/generated/videos/{metadata_file.parent.name}/{metadata_file.name}"
|
| 240 |
return {
|
| 241 |
"video_id": video_id,
|
| 242 |
"status": "complete",
|
| 243 |
+
"file_path": f"/generated/videos/{output_file.parent.name}/{output_file.name}",
|
| 244 |
"download_url": download_url,
|
| 245 |
"duration_seconds": duration_seconds,
|
| 246 |
"render_provider": "remotion",
|
|
|
|
| 248 |
"metadata_path": metadata_url,
|
| 249 |
"storage_provider": storage_object.provider if storage_object else "local",
|
| 250 |
"output_object_key": storage_object.object_key if storage_object else None,
|
| 251 |
+
"public_url": download_url,
|
| 252 |
"size_bytes": storage_object.size_bytes if storage_object else output_file.stat().st_size,
|
| 253 |
"validation": output_validation,
|
| 254 |
}
|
app/services/video_study_preview_renderer.py
CHANGED
|
@@ -172,6 +172,7 @@ def run_study_video_preview_render(job_id: str) -> None:
|
|
| 172 |
result = render_study_video_preview(
|
| 173 |
preview_payload,
|
| 174 |
video_id=job.id,
|
|
|
|
| 175 |
progress_callback=render_progress_callback,
|
| 176 |
)
|
| 177 |
render_duration_seconds = (
|
|
|
|
| 172 |
result = render_study_video_preview(
|
| 173 |
preview_payload,
|
| 174 |
video_id=job.id,
|
| 175 |
+
user_id=job.user_id,
|
| 176 |
progress_callback=render_progress_callback,
|
| 177 |
)
|
| 178 |
render_duration_seconds = (
|
tests/conftest.py
CHANGED
|
@@ -27,7 +27,6 @@ KNOWN_PREEXISTING_XFAILS = {
|
|
| 27 |
"tests/test_evidence_contract.py::TestAskEvidenceLabel::test_ask_no_source_returns_evidence_label",
|
| 28 |
"tests/test_beta_health.py::test_response_does_not_contain_api_key_field_names",
|
| 29 |
"tests/test_phase4_checkpoint4_ai_jobs.py::test_failed_validation_captures_error",
|
| 30 |
-
"tests/test_google_auth.py::test_google_callback_respects_beta_invite_gate",
|
| 31 |
"tests/test_live_failure_regressions.py::test_chained_simple_explanation_routes_to_fast_provider_before_sarvam",
|
| 32 |
"tests/test_live_failure_regressions.py::test_simple_explanation_fails_over_after_one_malformed_sarvam_response",
|
| 33 |
"tests/test_live_failure_regressions.py::test_video_job_list_response_accepts_legacy_completed_records",
|
|
@@ -64,6 +63,9 @@ def _configure_test_runtime_env(db_path: Path) -> None:
|
|
| 64 |
# not change behavior; prod-specific tests override this explicitly.
|
| 65 |
os.environ["ENVIRONMENT"] = "development"
|
| 66 |
os.environ["AUTH_ENABLED"] = "false"
|
|
|
|
|
|
|
|
|
|
| 67 |
os.environ["BETA_ACCESS_ENABLED"] = "false"
|
| 68 |
os.environ["AI_PROVIDER"] = "mock"
|
| 69 |
os.environ["AI_FALLBACK_TO_MOCK"] = "true"
|
|
@@ -158,6 +160,7 @@ def auth_client():
|
|
| 158 |
|
| 159 |
# Restore dev defaults so subsequent test fixtures are not affected.
|
| 160 |
os.environ["AUTH_ENABLED"] = "false"
|
|
|
|
| 161 |
os.environ["AUTH_PROVIDER"] = "dev"
|
| 162 |
os.environ["BETA_ACCESS_ENABLED"] = "false"
|
| 163 |
os.environ.pop("BETA_INVITE_CODE", None)
|
|
|
|
| 27 |
"tests/test_evidence_contract.py::TestAskEvidenceLabel::test_ask_no_source_returns_evidence_label",
|
| 28 |
"tests/test_beta_health.py::test_response_does_not_contain_api_key_field_names",
|
| 29 |
"tests/test_phase4_checkpoint4_ai_jobs.py::test_failed_validation_captures_error",
|
|
|
|
| 30 |
"tests/test_live_failure_regressions.py::test_chained_simple_explanation_routes_to_fast_provider_before_sarvam",
|
| 31 |
"tests/test_live_failure_regressions.py::test_simple_explanation_fails_over_after_one_malformed_sarvam_response",
|
| 32 |
"tests/test_live_failure_regressions.py::test_video_job_list_response_accepts_legacy_completed_records",
|
|
|
|
| 63 |
# not change behavior; prod-specific tests override this explicitly.
|
| 64 |
os.environ["ENVIRONMENT"] = "development"
|
| 65 |
os.environ["AUTH_ENABLED"] = "false"
|
| 66 |
+
os.environ["ALLOW_INSECURE_DEV_AUTH"] = "true"
|
| 67 |
+
os.environ["JWT_SECRET_KEY"] = "test-only-jwt-secret-that-is-at-least-32-characters"
|
| 68 |
+
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
| 69 |
os.environ["BETA_ACCESS_ENABLED"] = "false"
|
| 70 |
os.environ["AI_PROVIDER"] = "mock"
|
| 71 |
os.environ["AI_FALLBACK_TO_MOCK"] = "true"
|
|
|
|
| 160 |
|
| 161 |
# Restore dev defaults so subsequent test fixtures are not affected.
|
| 162 |
os.environ["AUTH_ENABLED"] = "false"
|
| 163 |
+
os.environ["ALLOW_INSECURE_DEV_AUTH"] = "true"
|
| 164 |
os.environ["AUTH_PROVIDER"] = "dev"
|
| 165 |
os.environ["BETA_ACCESS_ENABLED"] = "false"
|
| 166 |
os.environ.pop("BETA_INVITE_CODE", None)
|
tests/test_data_isolation.py
CHANGED
|
@@ -202,7 +202,7 @@ class TestBillingIsolation:
|
|
| 202 |
# Beta invite gate
|
| 203 |
# ---------------------------------------------------------------------------
|
| 204 |
|
| 205 |
-
@pytest.mark.
|
| 206 |
reason="Invite gate was removed by design — signup is now open "
|
| 207 |
"(see app/routes/auth.py signup: 'invite gate removed'). These tests assert "
|
| 208 |
"obsolete behavior."
|
|
|
|
| 202 |
# Beta invite gate
|
| 203 |
# ---------------------------------------------------------------------------
|
| 204 |
|
| 205 |
+
@pytest.mark.skipif(False,
|
| 206 |
reason="Invite gate was removed by design — signup is now open "
|
| 207 |
"(see app/routes/auth.py signup: 'invite gate removed'). These tests assert "
|
| 208 |
"obsolete behavior."
|
tests/test_google_auth.py
CHANGED
|
@@ -73,9 +73,13 @@ def test_google_callback_creates_user_and_returns_frontend_session(auth_client,
|
|
| 73 |
)
|
| 74 |
|
| 75 |
try:
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
| 77 |
response = auth_client.get(
|
| 78 |
f"/auth/google/callback?code=test-code&state={state}",
|
|
|
|
| 79 |
follow_redirects=False,
|
| 80 |
)
|
| 81 |
|
|
@@ -116,9 +120,13 @@ def test_google_callback_respects_beta_invite_gate(auth_client, monkeypatch):
|
|
| 116 |
)
|
| 117 |
|
| 118 |
try:
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
| 120 |
response = auth_client.get(
|
| 121 |
f"/auth/google/callback?code=test-code&state={state}",
|
|
|
|
| 122 |
follow_redirects=False,
|
| 123 |
)
|
| 124 |
|
|
@@ -130,3 +138,30 @@ def test_google_callback_respects_beta_invite_gate(auth_client, monkeypatch):
|
|
| 130 |
os.environ["BETA_ACCESS_ENABLED"] = "false"
|
| 131 |
os.environ.pop("BETA_INVITE_CODE", None)
|
| 132 |
_clear_google_env()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
)
|
| 74 |
|
| 75 |
try:
|
| 76 |
+
nonce = "test-google-state-nonce"
|
| 77 |
+
state = auth_routes._create_google_state(
|
| 78 |
+
next_path="/dashboard", invite_code=None, nonce=nonce
|
| 79 |
+
)
|
| 80 |
response = auth_client.get(
|
| 81 |
f"/auth/google/callback?code=test-code&state={state}",
|
| 82 |
+
cookies={auth_routes.GOOGLE_STATE_COOKIE: nonce},
|
| 83 |
follow_redirects=False,
|
| 84 |
)
|
| 85 |
|
|
|
|
| 120 |
)
|
| 121 |
|
| 122 |
try:
|
| 123 |
+
nonce = "test-google-state-nonce"
|
| 124 |
+
state = auth_routes._create_google_state(
|
| 125 |
+
next_path="/onboarding", invite_code=None, nonce=nonce
|
| 126 |
+
)
|
| 127 |
response = auth_client.get(
|
| 128 |
f"/auth/google/callback?code=test-code&state={state}",
|
| 129 |
+
cookies={auth_routes.GOOGLE_STATE_COOKIE: nonce},
|
| 130 |
follow_redirects=False,
|
| 131 |
)
|
| 132 |
|
|
|
|
| 138 |
os.environ["BETA_ACCESS_ENABLED"] = "false"
|
| 139 |
os.environ.pop("BETA_INVITE_CODE", None)
|
| 140 |
_clear_google_env()
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def test_google_callback_rejects_state_from_another_browser(auth_client, monkeypatch):
|
| 144 |
+
_set_google_env()
|
| 145 |
+
|
| 146 |
+
from app.routes import auth as auth_routes
|
| 147 |
+
|
| 148 |
+
monkeypatch.setattr(
|
| 149 |
+
auth_routes,
|
| 150 |
+
"_post_google_token",
|
| 151 |
+
lambda payload: (_ for _ in ()).throw(AssertionError("token exchange must not run")),
|
| 152 |
+
)
|
| 153 |
+
try:
|
| 154 |
+
state = auth_routes._create_google_state(
|
| 155 |
+
next_path="/dashboard", invite_code=None, nonce="browser-a"
|
| 156 |
+
)
|
| 157 |
+
response = auth_client.get(
|
| 158 |
+
f"/auth/google/callback?code=test-code&state={state}",
|
| 159 |
+
cookies={auth_routes.GOOGLE_STATE_COOKIE: "browser-b"},
|
| 160 |
+
follow_redirects=False,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
assert response.status_code == 302
|
| 164 |
+
params = _fragment_params(response.headers["location"])
|
| 165 |
+
assert "expired" in params["error"].lower()
|
| 166 |
+
finally:
|
| 167 |
+
_clear_google_env()
|
tests/test_launch_blocker_security.py
CHANGED
|
@@ -109,7 +109,15 @@ def test_invalid_forged_jwt_not_trusted_for_rate_limit_key(monkeypatch):
|
|
| 109 |
},
|
| 110 |
)
|
| 111 |
|
| 112 |
-
assert _rate_limit_key(request) == "ip:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
|
| 115 |
def test_valid_jwt_is_trusted_for_rate_limit_key(monkeypatch):
|
|
|
|
| 109 |
},
|
| 110 |
)
|
| 111 |
|
| 112 |
+
assert _rate_limit_key(request) == "ip:198.51.100.25"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_rate_limit_ignores_client_supplied_forwarded_for() -> None:
|
| 116 |
+
from app.main import _rate_limit_key
|
| 117 |
+
|
| 118 |
+
request = _request_with_headers({"X-Forwarded-For": "203.0.113.99"})
|
| 119 |
+
|
| 120 |
+
assert _rate_limit_key(request) == "ip:198.51.100.25"
|
| 121 |
|
| 122 |
|
| 123 |
def test_valid_jwt_is_trusted_for_rate_limit_key(monkeypatch):
|
tests/test_security_scan_regressions.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def _signup(client, *, email: str) -> tuple[str, str]:
|
| 5 |
+
response = client.post(
|
| 6 |
+
"/auth/signup",
|
| 7 |
+
json={"name": "Security Test", "email": email, "password": "Pass123!beta"},
|
| 8 |
+
)
|
| 9 |
+
assert response.status_code == 201, response.text
|
| 10 |
+
body = response.json()
|
| 11 |
+
return body["access_token"], body["user"]["id"]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _auth(token: str) -> dict[str, str]:
|
| 15 |
+
return {"Authorization": f"Bearer {token}"}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_generated_audio_requires_owner_authentication(auth_client, tmp_path, monkeypatch) -> None:
|
| 19 |
+
from app.core.config import get_settings
|
| 20 |
+
|
| 21 |
+
monkeypatch.setenv("TTS_OUTPUT_DIR", str(tmp_path / "audio"))
|
| 22 |
+
get_settings.cache_clear()
|
| 23 |
+
token_a, user_a = _signup(auth_client, email="media-owner@example.test")
|
| 24 |
+
token_b, _ = _signup(auth_client, email="media-other@example.test")
|
| 25 |
+
|
| 26 |
+
artifact = get_settings().resolved_tts_output_dir / user_a / "video_1" / "scene-001.wav"
|
| 27 |
+
artifact.parent.mkdir(parents=True, exist_ok=True)
|
| 28 |
+
artifact.write_bytes(b"private audio")
|
| 29 |
+
url = f"/generated/audio/{user_a}/video_1/scene-001.wav"
|
| 30 |
+
|
| 31 |
+
auth_client.cookies.clear()
|
| 32 |
+
assert auth_client.get(url).status_code == 401
|
| 33 |
+
assert auth_client.get(url, headers=_auth(token_a)).content == b"private audio"
|
| 34 |
+
assert auth_client.get(url, headers=_auth(token_b)).status_code == 404
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_signup_sets_http_only_media_cookie(auth_client) -> None:
|
| 38 |
+
response = auth_client.post(
|
| 39 |
+
"/auth/signup",
|
| 40 |
+
json={
|
| 41 |
+
"name": "Cookie Test",
|
| 42 |
+
"email": "media-cookie@example.test",
|
| 43 |
+
"password": "Pass123!beta",
|
| 44 |
+
},
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
assert response.status_code == 201
|
| 48 |
+
cookie = response.headers["set-cookie"].lower()
|
| 49 |
+
assert "docdoe_media_token=" in cookie
|
| 50 |
+
assert "httponly" in cookie
|
| 51 |
+
assert "path=/generated" in cookie
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_source_text_payload_has_a_bounded_size(auth_client) -> None:
|
| 55 |
+
token, _ = _signup(auth_client, email="bounded-source@example.test")
|
| 56 |
+
|
| 57 |
+
response = auth_client.post(
|
| 58 |
+
"/sources/text",
|
| 59 |
+
headers=_auth(token),
|
| 60 |
+
json={
|
| 61 |
+
"title": "Too large",
|
| 62 |
+
"text": "x" * 200_001,
|
| 63 |
+
"source_type": "notes",
|
| 64 |
+
},
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
assert response.status_code == 422
|
tests/test_study_video_preview_render.py
CHANGED
|
@@ -177,7 +177,9 @@ def _fake_tts_success(*, tmp_path: Path, calls: list[dict[str, Any]]):
|
|
| 177 |
|
| 178 |
|
| 179 |
def _fake_render_success(calls: list[dict[str, Any]]):
|
| 180 |
-
def fake_render_study_video_preview(
|
|
|
|
|
|
|
| 181 |
calls.append(payload)
|
| 182 |
if progress_callback:
|
| 183 |
progress_callback(70, "rendering_preview_video")
|
|
|
|
| 177 |
|
| 178 |
|
| 179 |
def _fake_render_success(calls: list[dict[str, Any]]):
|
| 180 |
+
def fake_render_study_video_preview(
|
| 181 |
+
payload: dict[str, Any], *, video_id=None, user_id=None, progress_callback=None
|
| 182 |
+
) -> dict[str, Any]:
|
| 183 |
calls.append(payload)
|
| 184 |
if progress_callback:
|
| 185 |
progress_callback(70, "rendering_preview_video")
|