asnannp commited on
Commit
6515ef9
·
1 Parent(s): 9104d21

deploy: sync backend to Space root (learn-lesson HF cache fix)

Browse files
.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 is default and keeps current /generated/audio and /generated/videos URLs.
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
- # Local dev defaults keep all current flows working as the demo student.
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 for beta to prevent a single user from exhausting
164
- # Sarvam credits. RATE_LIMIT_AI_REQUESTS_PER_DAY applies per user (JWT sub) or
165
- # per IP when no token is present. Resets every 24 hours from first request.
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
- In this mode, protected endpoints automatically use a demo student user so upload, AI generation, previous-paper analysis, TTS, and video rendering still work without logging in.
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 AI uses `S3CompatibleStorageProvider` (boto3) for generated videos and audio in production. Local dev keeps the default `LocalStorageProvider`.
 
 
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 settings.environment == "production":
 
 
 
32
  raise HTTPException(
33
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
34
- detail="Authentication is misconfigured for production.",
35
  )
36
  return get_or_create_dev_user(db)
37
 
38
- if credentials is None or credentials.scheme.lower() != "bearer":
 
 
 
 
 
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 = "dev"
232
- auth_enabled: bool = False
 
 
 
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 = "change-this-local-dev-secret"
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 = False
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
- if current_settings.jwt_secret_key == _DEFAULT_JWT_SECRET:
199
- if is_prod:
200
- logger.error(
201
- "SECURITY: JWT_SECRET_KEY is the unsafe default value. "
202
- "Set a strong random 32+ character secret before serving real users."
203
- )
204
- raise RuntimeError(
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 is_prod and not current_settings.auth_enabled:
 
 
 
 
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 is_prod and not current_settings.rate_limit_enabled:
291
- logger.warning(
292
- "CONFIG WARNING: RATE_LIMIT_ENABLED is false in production. "
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
- forwarded = request.headers.get("X-Forwarded-For", "")
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.mount(
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 create_access_token, hash_password, require_user, verify_password
 
 
 
 
 
 
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(*, next_path: str, invite_code: str | None) -> str:
 
 
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
- # Open signup — no invite gate. (invite_code accepted for compatibility, ignored.)
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
- return RedirectResponse(
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
- @router.post("/signup", response_model=AuthTokenResponse, status_code=status.HTTP_201_CREATED)
242
- def signup(payload: AuthSignupRequest, db: Session = Depends(get_db)) -> AuthTokenResponse:
243
- # Open signup — invite gate removed. invite_code field still accepted on the
244
- # request for backward compatibility but is no longer required or checked.
 
 
 
 
 
 
 
 
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(payload: AuthLoginRequest, db: Session = Depends(get_db)) -> AuthTokenResponse:
 
 
 
 
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
- state_token = _create_google_state(next_path=next, invite_code=invite_code)
 
 
 
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
- return RedirectResponse(
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
- return _auth_redirect_for_user(user, next_path)
 
 
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 via Deepgram. Access
4
- mirrors the tuition beta: anonymous callers are allowed (the beta is
5
- login-less) but the clip size is capped so the anonymous surface can't run up
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 get_current_user_optional
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. Bounds the anonymous
28
- # surface's cost without getting in the way of a normal class recording.
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 | None = Depends(get_current_user_optional),
36
  ) -> TranscriptionResponse:
37
  settings = get_settings()
38
 
39
- audio = await file.read()
40
- if len(audio) > MAX_AUDIO_BYTES:
 
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 = transcribe_audio(
 
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: str = "icon"
28
- name: str
29
- label: str | None = None
30
- position: str | None = None
31
- animation: str | None = None
32
 
33
- model_config = {"extra": "allow"}
34
 
35
 
36
  class VideoSceneAudioInput(BaseModel):
37
  scene_id: int | None = None
38
  id: int | None = None
39
- type: str = "meaning"
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[str] = Field(default_factory=list)
47
- visual_hint: str | None = None
48
- visual_elements: list[VideoVisualElement] = Field(default_factory=list)
49
- mascot_expression: str | None = None
50
- transition: str | None = None
51
- purpose: str | None = None
52
- learning_purpose: str | None = None
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": "allow"}
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: str = "English"
68
- style: str = "clean_explainer"
69
  visual_style: VisualStyle = "clean_explainer"
70
- video_format: str = "16:9"
71
  voice_mode: VoiceMode | None = None
72
- scenes: list[VideoSceneAudioInput] = Field(default_factory=list)
73
 
74
- model_config = {"extra": "allow"}
75
 
76
 
77
  class GenerateAudioRequest(BaseModel):
78
  scene_plan: VideoScenePlanAudioInput
79
  voice_mode: VoiceMode = "english_soft"
80
- voice: str = "nila"
81
- language: str = "English"
82
- languages: list[str] | None = None
83
- provider: str | None = None
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: str = "nila"
140
- language: str | None = None
141
- provider: str | None = None
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: everything is keyed by a content hash of (topic, lesson,
11
- level, voice). The first student to open a lesson pays for one LLM call plus
12
- Deepgram audio; every student after that reuses the cached manifest and audio
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 stampede: many students opening the same uncached lesson at once
67
- # should share one authoring job, not N parallel LLM bills.
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, lesson_title: str, level: str, medium: str, voice: 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
- "schema": "learn-lesson-v1",
 
 
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=f"/generated/learn-anything/{out_dir.name}/{audio_path.name}",
 
 
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 by content hash.
965
 
966
- Cache is shared across all students: the first open pays for authoring;
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
- key = lesson_hash(topic, lesson_title, level, medium, voice)
 
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
- # Hard fallback: rebuild under /tmp so students still get a class.
1009
- emergency = (
1010
- Path(os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp")
1011
- / "docdoe-learn-lessons"
1012
- / key
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
- # Local files are owned by the media services; cleanup is handled separately.
73
- logger.debug("Local storage delete is a no-op for object_key=%s", object_key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- try:
943
- storage_object = storage_provider.upload_file(
944
- result.file_path,
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": storage_object.public_url if storage_object else audio_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
- try:
122
- storage_provider = get_storage_provider()
123
- storage_object = storage_provider.upload_file(
124
- output_file,
125
- build_object_key(settings.storage_video_prefix, output_file.name),
126
- content_type="video/mp4",
127
  )
128
- download_url = storage_object.public_url
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": storage_object.public_url if storage_object else download_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
- try:
243
- storage_provider = get_storage_provider()
244
- storage_object = storage_provider.upload_file(
245
- output_file,
246
- build_object_key(settings.storage_video_prefix, output_file.name),
247
- content_type="video/mp4",
248
  )
249
- download_url = storage_object.public_url
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": storage_object.public_url if storage_object else download_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.skip(
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
- state = auth_routes._create_google_state(next_path="/dashboard", invite_code=None)
 
 
 
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
- state = auth_routes._create_google_state(next_path="/onboarding", invite_code=None)
 
 
 
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:203.0.113.44"
 
 
 
 
 
 
 
 
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(payload: dict[str, Any], *, video_id=None, progress_callback=None) -> dict[str, Any]:
 
 
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")