diff --git a/backend/app/api/v1/endpoints/videos.py b/backend/app/api/v1/endpoints/videos.py index f8994162..1a7d1455 100644 --- a/backend/app/api/v1/endpoints/videos.py +++ b/backend/app/api/v1/endpoints/videos.py @@ -167,6 +167,61 @@ def check_premium_access(db: Session, request: Request): raise HTTPException(status_code=403, detail="1080p and above quality levels are restricted to Premium members") +@router.get("/{video_id}/stream-url") +async def get_stream_url(video_id: str, request: Request, db: Session = Depends(get_db)): + """Return a CloudFront signed URL for the video's HLS manifest. + + Client fetches this ONCE, then loads manifest + all segments directly + from CloudFront — no backend proxy involved in actual streaming. + + Auth/premium check runs here (same logic as the old proxy endpoint). + If CloudFront signing is not configured, falls back to the raw CDN URL. + """ + quality = request.query_params.get("quality", "original") + if quality.lower() in ("1080p", "2k", "4k"): + check_premium_access(db, request) + + video = get_video_db(db, video_id) + if not video: + raise HTTPException(status_code=404, detail="Video not found") + + base_url = video.video_url + if not base_url: + raise HTTPException(status_code=400, detail="Video has no URL") + + # Resolve to CDN URL if stored as S3/HF path + if "amazonaws.com" in base_url or "monteeq.s3" in base_url or "cdn.monteeq.com" in base_url: + try: + if ".com/" in base_url: + parts = base_url.split(".com/") + if len(parts) > 1: + base_url = storage.get_url(parts[1]) + except Exception as e: + logger.warning(f"Failed to resolve CDN URL for stream-url: {e}") + + # If CloudFront signing is not configured, return the raw URL + # (the old proxy endpoint is still available as fallback) + from app.utils.cloudfront_signer import is_cloudfront_configured, generate_signed_url + + if not is_cloudfront_configured(): + return { + "url": base_url, + "expires_at": 0, + "signed": False, + } + + # Video duration + 1 hour buffer, capped at 6 hours + duration_buffer = min((video.duration or 3600) + 3600, 21600) + signed_url = generate_signed_url(base_url, duration_buffer) + + import time + return { + "url": signed_url, + "expires_at": int(time.time()) + duration_buffer, + "signed": True, + } + + @router.get("/{video_id}/stream/{sub_path:path}") @router.get("/{video_id}/stream") async def stream_video(video_id: str, request: Request, db: Session = Depends(get_db), sub_path: str = None): @@ -236,6 +291,8 @@ async def get_stream(): response_headers = { "Accept-Ranges": "bytes", "Content-Encoding": "identity", + "X-Deprecated": "true", + "X-Deprecation-Notice": "Use /stream-url for direct CDN access", } content_type = resp.headers.get("Content-Type") if content_type: @@ -326,6 +383,8 @@ async def get_res_stream(): response_headers = { "Accept-Ranges": "bytes", "Content-Encoding": "identity", + "X-Deprecated": "true", + "X-Deprecation-Notice": "Use /stream-url for direct CDN access", } content_type = resp.headers.get("Content-Type") if content_type: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 189732e7..bbf7d7b1 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -51,6 +51,10 @@ def _parse_origins(raw: str) -> list[str]: S3_ENDPOINT = _raw_endpoint.strip() or None # empty string → native AWS AWS_S3_USE_ACCELERATE = os.getenv("AWS_S3_USE_ACCELERATE", "false").lower() == "true" AWS_CLOUDFRONT_DOMAIN = os.getenv("AWS_CLOUDFRONT_DOMAIN", "") +CLOUDFRONT_KEY_PAIR_ID = os.getenv("CLOUDFRONT_KEY_PAIR_ID", "") +# NOTE: For production, consider migrating this to AWS Secrets Manager +# (fetched at startup, cached in memory) instead of an env var. +CLOUDFRONT_PRIVATE_KEY = os.getenv("CLOUDFRONT_PRIVATE_KEY", "") PAYSTACK_SECRET_KEY = os.getenv("PAYSTACK_SECRET_KEY", "") # STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "") diff --git a/backend/app/utils/cloudfront_signer.py b/backend/app/utils/cloudfront_signer.py new file mode 100644 index 00000000..f74080bf --- /dev/null +++ b/backend/app/utils/cloudfront_signer.py @@ -0,0 +1,74 @@ +""" +CloudFront signed URL generation for direct CDN video streaming. + +Uses a custom policy with a path wildcard so the signed URL covers: + - The master manifest (master.m3u8) + - All segment files (.ts/.m4s) referenced by the manifest + +AWS requires SHA-1 for CloudFront signing (hard requirement, not a choice). +""" + +import re +import logging +from datetime import datetime, timezone +from urllib.parse import quote + +from app.core import config + +logger = logging.getLogger(__name__) + + +def _rsa_signer(message: bytes) -> bytes: + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import padding + + private_key = serialization.load_pem_private_key( + config.CLOUDFRONT_PRIVATE_KEY.encode(), password=None + ) + return private_key.sign(message, padding.PKCS1v15(), hashes.SHA1()) + + +def _get_signer(): + from botocore.signers import CloudFrontSigner + return CloudFrontSigner(config.CLOUDFRONT_KEY_PAIR_ID, _rsa_signer) + + +def generate_signed_url(resource_url: str, expires_in_seconds: int = 21600) -> str: + """ + Generate a CloudFront signed URL with a custom policy. + + The policy covers the video's directory path with a wildcard (*), + so the manifest AND all segment files are accessible with one signature. + + Args: + resource_url: Full CDN URL to the master manifest, e.g. + https://d123.cloudfront.net/videos/123/hls/master.m3u8 + expires_in_seconds: Time until the signed URL expires (default 6h). + + Returns: + Signed URL string. + """ + signer = _get_signer() + + # Build wildcard resource: replace the filename with * + # https://d123.cloudfront.net/videos/123/hls/master.m3u8 + # → https://d123.cloudfront.net/videos/123/hls/* + base_path = re.sub(r'[^/]+\.m3u8$', '*', resource_url) + + expiration = datetime.now(timezone.utc).timestamp() + expires_in_seconds + expiration_dt = datetime.fromtimestamp(expiration, tz=timezone.utc) + + policy = signer.build_policy( + resource=base_path, + date_less_than=expiration_dt, + ) + + return signer.generate_presigned_url( + resource_url, + policy=policy, + ) + + +def is_cloudfront_configured() -> bool: + """Check if CloudFront signing is configured.""" + return bool(config.CLOUDFRONT_KEY_PAIR_ID and config.CLOUDFRONT_PRIVATE_KEY) diff --git a/backend/requirements.txt b/backend/requirements.txt index 9be1c43f..b9908ace 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -28,3 +28,4 @@ brotli fastapi-cache2[redis] tenacity stripe +cryptography diff --git a/next-app/src/app/(embed)/embed/[id]/page.js b/next-app/src/app/(embed)/embed/[id]/page.js index 77353125..41ca6f16 100644 --- a/next-app/src/app/(embed)/embed/[id]/page.js +++ b/next-app/src/app/(embed)/embed/[id]/page.js @@ -37,6 +37,7 @@ export default async function EmbedPage({ params, searchParams }) { const { id } = params; const autoplay = searchParams?.autoplay === '1'; const video = await loadVideo(id); + const apiOrigin = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/$/, ''); if (!video) { return ( @@ -60,26 +61,30 @@ export default async function EmbedPage({ params, searchParams }) { const watchUrl = `${siteOrigin()}/watch/${video.id}`; return ( -
- + <> + + +
+
+ ); } diff --git a/next-app/src/app/(main)/watch/[id]/page.js b/next-app/src/app/(main)/watch/[id]/page.js index 439e4086..e9953b4a 100644 --- a/next-app/src/app/(main)/watch/[id]/page.js +++ b/next-app/src/app/(main)/watch/[id]/page.js @@ -142,9 +142,12 @@ export default async function WatchPage({ params }) { const { video, comments, relatedVideos, followersCount, isFollowing } = data; const canonical = `${siteOrigin()}/watch/${video.id}`; const jsonLd = buildVideoJsonLd(video, canonical); + const apiOrigin = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/$/, ''); return ( <> + +