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>
2026-09-16 18:44:30 +00:00
|
|
|
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:
|
2026-09-17 17:22:22 +00:00
|
|
|
# This endpoint is public (the Connect page needs `authenticated` before
|
|
|
|
|
# logging in), so the connected account's email must only be revealed to a
|
|
|
|
|
# request holding a valid session.
|
|
|
|
|
authenticated = bool(request.session.get("authenticated"))
|
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>
2026-09-16 18:44:30 +00:00
|
|
|
connected = google_oauth.is_connected(db)
|
2026-09-17 17:22:22 +00:00
|
|
|
email = google_oauth.get_connected_email(db) if authenticated and connected else None
|
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>
2026-09-16 18:44:30 +00:00
|
|
|
return {
|
2026-09-17 17:22:22 +00:00
|
|
|
"authenticated": authenticated,
|
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>
2026-09-16 18:44:30 +00:00
|
|
|
"connected": connected,
|
2026-09-17 17:22:22 +00:00
|
|
|
"email": email,
|
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>
2026-09-16 18:44:30 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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}
|