feat: image generation overhaul (model-aware text, dim clamping, \.30 pricing), event-driven dashboard cache invalidation, SEO insights (AI visibility, GSC, keyword gap), YouTube OAuth/publish, blog writer & content planning improvements, scheduler monitoring updates
This commit is contained in:
493
backend/services/youtube/youtube_oauth_service.py
Normal file
493
backend/services/youtube/youtube_oauth_service.py
Normal file
@@ -0,0 +1,493 @@
|
||||
"""
|
||||
YouTube OAuth2 Service
|
||||
Handles Google OAuth2 authentication for YouTube Data API v3.
|
||||
Supports token encryption, auto-refresh, and per-user multi-token storage.
|
||||
|
||||
Pattern: follows GSCService (Google OAuth flow) + WordPressOAuthService (Fernet encryption + rich schema).
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from google.auth.transport.requests import Request as GoogleRequest
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
from googleapiclient.discovery import build
|
||||
from cryptography.fernet import Fernet
|
||||
from loguru import logger
|
||||
|
||||
from services.database import get_user_db_path
|
||||
|
||||
|
||||
class YouTubeOAuthService:
|
||||
"""Manages YouTube OAuth2 authentication flow and token storage."""
|
||||
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/youtube.upload",
|
||||
"https://www.googleapis.com/auth/youtube.readonly",
|
||||
"https://www.googleapis.com/auth/youtube.force-ssl",
|
||||
]
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = db_path
|
||||
|
||||
# Load Google OAuth credentials
|
||||
self.client_id = os.getenv("GOOGLE_CLIENT_ID", "")
|
||||
self.client_secret = os.getenv("GOOGLE_CLIENT_SECRET", "")
|
||||
self.project_id = os.getenv("GOOGLE_PROJECT_ID", "alwrity")
|
||||
|
||||
# Redirect URI
|
||||
default_redirect = "http://localhost:8000/api/youtube/oauth/callback"
|
||||
self.redirect_uri = os.getenv("YOUTUBE_REDIRECT_URI", default_redirect)
|
||||
|
||||
# Token encryption
|
||||
self.token_encryption_key = os.getenv(
|
||||
"YOUTUBE_TOKEN_ENCRYPTION_KEY"
|
||||
) or os.getenv("OAUTH_TOKEN_ENCRYPTION_KEY")
|
||||
self._fernet: Fernet = self._initialize_fernet()
|
||||
self._migration_done: set = set()
|
||||
|
||||
# Build client config for google_auth_oauthlib
|
||||
self.client_config = self._build_client_config()
|
||||
|
||||
# Validate
|
||||
if not self.client_id or not self.client_secret:
|
||||
logger.error(
|
||||
"YouTube OAuth: GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET not set. "
|
||||
"YouTube upload will not work until these are configured."
|
||||
)
|
||||
|
||||
def _initialize_fernet(self) -> Fernet:
|
||||
if not self.token_encryption_key:
|
||||
raise ValueError(
|
||||
"YOUTUBE_TOKEN_ENCRYPTION_KEY (or OAUTH_TOKEN_ENCRYPTION_KEY) is not set. "
|
||||
"OAuth tokens must be encrypted at rest. "
|
||||
"Generate a key: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
|
||||
)
|
||||
try:
|
||||
return Fernet(self.token_encryption_key.encode("utf-8"))
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid YOUTUBE_TOKEN_ENCRYPTION_KEY: {e}")
|
||||
|
||||
def _encrypt_token(self, token: Optional[str]) -> Optional[str]:
|
||||
if not token:
|
||||
return None
|
||||
return self._fernet.encrypt(token.encode("utf-8")).decode("utf-8")
|
||||
|
||||
def _decrypt_token(self, token_blob: Optional[str]) -> Optional[str]:
|
||||
if not token_blob:
|
||||
return None
|
||||
try:
|
||||
return self._fernet.decrypt(token_blob.encode("utf-8")).decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube OAuth: token decryption failed: {e}")
|
||||
return None
|
||||
|
||||
def _is_likely_encrypted_blob(self, value: Optional[str]) -> bool:
|
||||
return bool(value and value.startswith("gAAAAA"))
|
||||
|
||||
def _build_client_config(self) -> Optional[Dict[str, Any]]:
|
||||
if not self.client_id or not self.client_secret:
|
||||
return None
|
||||
return {
|
||||
"web": {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"project_id": self.project_id,
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"redirect_uris": [self.redirect_uri],
|
||||
"javascript_origins": [],
|
||||
}
|
||||
}
|
||||
|
||||
def _get_db_path(self, user_id: str) -> str:
|
||||
return get_user_db_path(user_id)
|
||||
|
||||
def _init_db(self, user_id: str):
|
||||
db_path = self._get_db_path(user_id)
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS youtube_oauth_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT,
|
||||
token_type TEXT DEFAULT 'bearer',
|
||||
expires_at TIMESTAMP,
|
||||
scope TEXT,
|
||||
channel_id TEXT,
|
||||
channel_name TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT TRUE
|
||||
)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS youtube_oauth_states (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
state TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP DEFAULT (datetime('now', '+10 minutes'))
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
logger.debug(f"YouTube OAuth tables initialized for user {user_id}")
|
||||
|
||||
def _migrate_plaintext_tokens_if_needed(self, conn: sqlite3.Connection, user_id: str) -> None:
|
||||
if not self._fernet or user_id in self._migration_done:
|
||||
return
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT id, access_token, refresh_token FROM youtube_oauth_tokens WHERE user_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
migrated = 0
|
||||
for token_id, access_token, refresh_token in rows:
|
||||
needs_access = access_token and not self._is_likely_encrypted_blob(access_token)
|
||||
needs_refresh = refresh_token and not self._is_likely_encrypted_blob(refresh_token)
|
||||
if not (needs_access or needs_refresh):
|
||||
continue
|
||||
enc_access = self._encrypt_token(access_token) if needs_access else access_token
|
||||
enc_refresh = self._encrypt_token(refresh_token) if needs_refresh else refresh_token
|
||||
cursor.execute(
|
||||
"UPDATE youtube_oauth_tokens SET access_token = ?, refresh_token = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
|
||||
(enc_access, enc_refresh, token_id, user_id),
|
||||
)
|
||||
migrated += 1
|
||||
if migrated:
|
||||
conn.commit()
|
||||
logger.info(f"YouTube OAuth token migration completed for user {user_id}; rows={migrated}")
|
||||
self._migration_done.add(user_id)
|
||||
|
||||
def generate_authorization_url(self, user_id: str) -> Optional[str]:
|
||||
"""Generate Google OAuth authorization URL for YouTube scopes."""
|
||||
try:
|
||||
if not self.client_config:
|
||||
logger.error("YouTube OAuth: client config not available")
|
||||
return None
|
||||
|
||||
self._init_db(user_id)
|
||||
|
||||
flow = Flow.from_client_config(
|
||||
self.client_config,
|
||||
scopes=self.SCOPES,
|
||||
redirect_uri=self.redirect_uri,
|
||||
autogenerate_code_verifier=False,
|
||||
)
|
||||
|
||||
random_state = secrets.token_urlsafe(32)
|
||||
state = f"{user_id}:{random_state}"
|
||||
|
||||
authorization_url, _ = flow.authorization_url(
|
||||
access_type="offline",
|
||||
include_granted_scopes="true",
|
||||
prompt="consent",
|
||||
state=state,
|
||||
)
|
||||
|
||||
# Store state for callback verification
|
||||
db_path = self._get_db_path(user_id)
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT OR REPLACE INTO youtube_oauth_states (state, user_id) VALUES (?, ?)",
|
||||
(state, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"YouTube OAuth URL generated for user {user_id}")
|
||||
return authorization_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube OAuth: failed to generate auth URL for {user_id}: {e}")
|
||||
return None
|
||||
|
||||
def handle_oauth_callback(self, authorization_code: str, state: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle OAuth callback — exchange code for tokens, store them.
|
||||
|
||||
Returns: dict with 'success' key. On success also 'channel_id', 'channel_name'.
|
||||
"""
|
||||
try:
|
||||
if ":" not in state:
|
||||
logger.error(f"YouTube OAuth: invalid state format: {state}")
|
||||
return {"success": False, "error": "Invalid state format"}
|
||||
|
||||
user_id = state.split(":")[0]
|
||||
db_path = self._get_db_path(user_id)
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
logger.error(f"YouTube OAuth: user DB not found for {user_id}")
|
||||
return {"success": False, "error": "User database not found"}
|
||||
|
||||
# Verify state
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT user_id FROM youtube_oauth_states WHERE state = ?", (state,))
|
||||
if not cursor.fetchone():
|
||||
logger.error(f"YouTube OAuth: invalid/expired state for {user_id}")
|
||||
return {"success": False, "error": "Invalid or expired state"}
|
||||
|
||||
if not self.client_config:
|
||||
return {"success": False, "error": "Client config not loaded"}
|
||||
|
||||
# Exchange code for tokens
|
||||
flow = Flow.from_client_config(
|
||||
self.client_config,
|
||||
scopes=self.SCOPES,
|
||||
redirect_uri=self.redirect_uri,
|
||||
autogenerate_code_verifier=False,
|
||||
)
|
||||
flow.fetch_token(code=authorization_code)
|
||||
google_credentials = flow.credentials
|
||||
|
||||
# Clean up state
|
||||
try:
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("DELETE FROM youtube_oauth_states WHERE state = ?", (state,))
|
||||
conn.commit()
|
||||
except Exception as cleanup_err:
|
||||
logger.warning(f"YouTube OAuth: state cleanup failed: {cleanup_err}")
|
||||
|
||||
# Fetch channel info
|
||||
channel_info = self._fetch_channel_info(google_credentials)
|
||||
|
||||
# Save tokens
|
||||
save_result = self._save_tokens(
|
||||
user_id=user_id,
|
||||
credentials=google_credentials,
|
||||
channel_id=channel_info.get("channel_id", ""),
|
||||
channel_name=channel_info.get("channel_name", ""),
|
||||
)
|
||||
|
||||
if not save_result:
|
||||
return {"success": False, "error": "Failed to save tokens"}
|
||||
|
||||
logger.info(f"YouTube OAuth: user {user_id} authorized — channel: {channel_info.get('channel_name', 'unknown')}")
|
||||
return {
|
||||
"success": True,
|
||||
"channel_id": channel_info.get("channel_id", ""),
|
||||
"channel_name": channel_info.get("channel_name", ""),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube OAuth: callback error: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _fetch_channel_info(self, credentials: Credentials) -> Dict[str, str]:
|
||||
"""Fetch authenticated user's YouTube channel info."""
|
||||
try:
|
||||
youtube = build("youtube", "v3", credentials=credentials, cache_discovery=False)
|
||||
request = youtube.channels().list(part="snippet", mine=True)
|
||||
response = request.execute()
|
||||
items = response.get("items", [])
|
||||
if items:
|
||||
return {
|
||||
"channel_id": items[0].get("id", ""),
|
||||
"channel_name": items[0].get("snippet", {}).get("title", ""),
|
||||
}
|
||||
logger.warning("YouTube OAuth: no channel found for authenticated user")
|
||||
return {"channel_id": "", "channel_name": ""}
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube OAuth: failed to fetch channel info: {e}")
|
||||
return {"channel_id": "", "channel_name": ""}
|
||||
|
||||
def _save_tokens(
|
||||
self,
|
||||
user_id: str,
|
||||
credentials: Credentials,
|
||||
channel_id: str = "",
|
||||
channel_name: str = "",
|
||||
) -> bool:
|
||||
"""Save OAuth tokens to per-user database with encryption."""
|
||||
try:
|
||||
self._init_db(user_id)
|
||||
db_path = self._get_db_path(user_id)
|
||||
|
||||
expires_at = None
|
||||
if credentials.expiry:
|
||||
expires_at = credentials.expiry.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
enc_access = self._encrypt_token(credentials.token) or ""
|
||||
enc_refresh = self._encrypt_token(credentials.refresh_token)
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
self._migrate_plaintext_tokens_if_needed(conn, user_id)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO youtube_oauth_tokens
|
||||
(user_id, access_token, refresh_token, token_type, expires_at, scope, channel_id, channel_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
enc_access,
|
||||
enc_refresh,
|
||||
"bearer",
|
||||
expires_at,
|
||||
" ".join(self.SCOPES),
|
||||
channel_id,
|
||||
channel_name,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"YouTube OAuth: tokens saved for user {user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube OAuth: failed to save tokens for {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_valid_credentials(self, user_id: str, token_id: Optional[int] = None) -> Optional[Credentials]:
|
||||
"""
|
||||
Load and (if needed) refresh credentials for a user.
|
||||
|
||||
Args:
|
||||
user_id: Clerk user ID
|
||||
token_id: Specific token row ID; if None, uses the most recent active token.
|
||||
|
||||
Returns:
|
||||
google.oauth2.credentials.Credentials or None
|
||||
"""
|
||||
try:
|
||||
db_path = self._get_db_path(user_id)
|
||||
if not os.path.exists(db_path):
|
||||
return None
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
if token_id:
|
||||
cursor.execute(
|
||||
"SELECT id, access_token, refresh_token, expires_at FROM youtube_oauth_tokens WHERE id = ? AND user_id = ? AND is_active = 1",
|
||||
(token_id, user_id),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"SELECT id, access_token, refresh_token, expires_at FROM youtube_oauth_tokens WHERE user_id = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1",
|
||||
(user_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.warning(f"YouTube OAuth: no active tokens for user {user_id}")
|
||||
return None
|
||||
|
||||
db_id, enc_access, enc_refresh, expires_at_str = row
|
||||
|
||||
access_token = self._decrypt_token(enc_access)
|
||||
refresh_token = self._decrypt_token(enc_refresh)
|
||||
|
||||
if not access_token:
|
||||
logger.error(f"YouTube OAuth: cannot decrypt access token for user {user_id}")
|
||||
return None
|
||||
|
||||
# Build Credentials object (Google lib handles refresh automatically)
|
||||
creds = Credentials(
|
||||
token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_uri="https://oauth2.googleapis.com/token",
|
||||
client_id=self.client_id,
|
||||
client_secret=self.client_secret,
|
||||
scopes=self.SCOPES,
|
||||
)
|
||||
|
||||
# Auto-refresh if expired
|
||||
if creds.expired:
|
||||
if creds.refresh_token:
|
||||
try:
|
||||
creds.refresh(GoogleRequest())
|
||||
self._update_stored_token(user_id, db_id, creds)
|
||||
logger.info(f"YouTube OAuth: token refreshed for user {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube OAuth: token refresh failed for {user_id}: {e}")
|
||||
return None
|
||||
else:
|
||||
logger.warning(f"YouTube OAuth: token expired, no refresh token for {user_id}")
|
||||
return None
|
||||
|
||||
return creds
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube OAuth: get_valid_credentials error for {user_id}: {e}")
|
||||
return None
|
||||
|
||||
def _update_stored_token(self, user_id: str, token_id: int, credentials: Credentials):
|
||||
"""Update stored token after refresh."""
|
||||
try:
|
||||
db_path = self._get_db_path(user_id)
|
||||
enc_access = self._encrypt_token(credentials.token) or ""
|
||||
enc_refresh = self._encrypt_token(credentials.refresh_token)
|
||||
expires_at = None
|
||||
if credentials.expiry:
|
||||
expires_at = credentials.expiry.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE youtube_oauth_tokens SET access_token = ?, refresh_token = ?, expires_at = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
|
||||
(enc_access, enc_refresh, expires_at, token_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube OAuth: failed to update stored token for {user_id}: {e}")
|
||||
|
||||
def get_connection_status(self, user_id: str) -> Dict[str, Any]:
|
||||
"""Get YouTube connection status for a user."""
|
||||
try:
|
||||
db_path = self._get_db_path(user_id)
|
||||
if not os.path.exists(db_path):
|
||||
return {"connected": False, "channels": []}
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT id, channel_id, channel_name, expires_at, created_at, is_active FROM youtube_oauth_tokens WHERE user_id = ? ORDER BY created_at DESC",
|
||||
(user_id,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
channels = []
|
||||
for row in rows:
|
||||
channel = {
|
||||
"token_id": row[0],
|
||||
"channel_id": row[1] or "",
|
||||
"channel_name": row[2] or "",
|
||||
"expires_at": row[3],
|
||||
"connected_at": row[4],
|
||||
"is_active": bool(row[5]),
|
||||
}
|
||||
channels.append(channel)
|
||||
|
||||
return {"connected": len(channels) > 0, "channels": channels}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube OAuth: connection status error for {user_id}: {e}")
|
||||
return {"connected": False, "channels": [], "error": str(e)}
|
||||
|
||||
def revoke_token(self, user_id: str, token_id: int) -> bool:
|
||||
"""Deactivate a specific token."""
|
||||
try:
|
||||
db_path = self._get_db_path(user_id)
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE youtube_oauth_tokens SET is_active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
|
||||
(token_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"YouTube OAuth: token {token_id} revoked for user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube OAuth: revoke error for {user_id}: {e}")
|
||||
return False
|
||||
Reference in New Issue
Block a user