Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck - Google OAuth (single allowed account), encrypted refresh token storage - Subscriptions sync with pagination, uploads playlist batch fetch - Categories CRUD, many-to-many channel assignment, category filtering - Video sync (playlistItems + videos.list batching), cached feed with cursor pagination, background scheduler (APScheduler) - Video detail page with YouTube embed player - SPA fallback routing, optimistic UI updates, client-side query caching 40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes, cursor pagination, and category filtering. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
0ed20bb838
90 changed files with 8545 additions and 0 deletions
96
backend/app/api/auth.py
Normal file
96
backend/app/api/auth.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.core.auth_dependency import require_session
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.services import google_oauth, sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/auth/status")
|
||||
def auth_status(request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
connected = google_oauth.is_connected(db)
|
||||
return {
|
||||
"authenticated": bool(request.session.get("authenticated")),
|
||||
"connected": connected,
|
||||
"email": google_oauth.get_connected_email(db) if connected else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/auth/google/start")
|
||||
def google_start(request: Request):
|
||||
auth_url, state = google_oauth.build_authorization_url()
|
||||
request.session["oauth_state"] = state
|
||||
return RedirectResponse(auth_url)
|
||||
|
||||
|
||||
def _run_initial_sync() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
sync.sync_subscriptions(db)
|
||||
sync.sync_videos(db)
|
||||
except Exception:
|
||||
logger.exception("Initial sync after OAuth failed")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/auth/google/callback")
|
||||
def google_callback(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
code: str | None = None,
|
||||
state: str | None = None,
|
||||
error: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
expected_state = request.session.pop("oauth_state", None)
|
||||
|
||||
if error:
|
||||
logger.warning("Google OAuth returned error: %s", error)
|
||||
return RedirectResponse(f"{settings.app_base_url}/?auth_error=google_error")
|
||||
|
||||
if not code or not state or not expected_state or not secrets.compare_digest(state, expected_state):
|
||||
logger.warning("Google OAuth callback with invalid/missing state")
|
||||
return RedirectResponse(f"{settings.app_base_url}/?auth_error=invalid_state")
|
||||
|
||||
try:
|
||||
credentials = google_oauth.exchange_code(code, state)
|
||||
except Exception:
|
||||
logger.exception("Failed to exchange Google OAuth code")
|
||||
return RedirectResponse(f"{settings.app_base_url}/?auth_error=exchange_failed")
|
||||
|
||||
try:
|
||||
userinfo = google_oauth.fetch_userinfo(credentials.token)
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Google userinfo")
|
||||
return RedirectResponse(f"{settings.app_base_url}/?auth_error=userinfo_failed")
|
||||
|
||||
email = (userinfo.get("email") or "").lower()
|
||||
allowed_email = settings.allowed_google_email.lower()
|
||||
|
||||
if not allowed_email or email != allowed_email:
|
||||
logger.warning("Rejected Google OAuth login for disallowed account")
|
||||
google_oauth.revoke_token(credentials.refresh_token or credentials.token)
|
||||
return RedirectResponse(f"{settings.app_base_url}/?auth_error=account_not_allowed")
|
||||
|
||||
google_oauth.store_credentials(db, email, credentials)
|
||||
request.session["authenticated"] = True
|
||||
|
||||
background_tasks.add_task(_run_initial_sync)
|
||||
|
||||
return RedirectResponse(f"{settings.app_base_url}/")
|
||||
|
||||
|
||||
@router.post("/auth/logout")
|
||||
def logout(request: Request, _: None = Depends(require_session)):
|
||||
request.session.clear()
|
||||
return {"ok": True}
|
||||
Loading…
Add table
Add a link
Reference in a new issue