//! Streaming and download handlers for content access. use axum::{ Json, extract::{Path, State}, response::IntoResponse, }; use serde::Serialize; use sqlx::PgPool; use crate::{ AppStorage, auth::MaybeUserVerified, db::{self, ContentData, ItemId, VersionId}, error::{AppError, Result, ResultExt}, pricing, }; /// JSON response containing a presigned streaming/download URL. #[derive(Debug, Serialize)] pub(super) struct StreamUrlResponse { pub stream_url: String, pub expires_in: u64, } /// JSON response containing a presigned download URL for a version. #[derive(Debug, Serialize)] pub(super) struct VersionDownloadResponse { pub download_url: String, pub file_name: Option, pub expires_in: u64, #[serde(skip_serializing_if = "Option::is_none")] pub license_url: Option, } /// Resolve a content URL for downloadable media. Always a presigned, expiring /// URL, even for free content. /// /// Downloadable media (audio/video/downloads) lives ONLY in the private bucket; /// it is never served unsigned from the CDN. Free content previously resolved to /// a permanent unsigned `{cdn}/{key}` URL, but that (a) required the media bucket /// to be publicly readable, which would expose paid content sharing the same /// bucket, and (b) made free->paid revocation impossible (the URL never /// expired). Presigning free media closes both: the private bucket stays private /// and a short-lived URL self-revokes. Only immutably-public IMAGE content /// (covers/gallery) is served unsigned, and that lives in the separate public /// bucket behind the CDN. async fn resolve_content_url( s3: &dyn crate::storage::StorageBackend, s3_key: &str, expiry_secs: u64, ) -> Result<(String, u64)> { let url = s3 .presign_download( &crate::storage::S3Key::from_stored(s3_key), Some(expiry_secs), ) .await .context("presign download for content")?; Ok((url, expiry_secs)) } /// Generate a presigned URL for streaming/downloading content /// /// GET /api/stream/{item_id} /// /// Access control: /// - Free items: Anyone can access /// - Paid items: Must be logged in and have purchased the item #[tracing::instrument(skip_all, name = "storage::stream_url", fields(item_id))] pub(super) async fn stream_url( State(db): State, State(storage): State, MaybeUserVerified(maybe_user): MaybeUserVerified, Path(item_id): Path, ) -> Result { tracing::Span::current().record("item_id", tracing::field::display(&item_id)); // Check if S3 is configured let s3 = storage.require_s3()?; let item = db::items::get_item_by_id(&db, item_id) .await? .ok_or(AppError::NotFound)?; // Single-query access check: ownership, purchase, subscription, bundle let user_id = maybe_user.as_ref().map(|u| u.id); let access = db::items::check_item_access(&db, item_id, user_id) .await? .ok_or(AppError::NotFound)?; let is_creator = user_id.is_some_and(|uid| uid == access.owner_id); // Draft items can only be streamed by their creator (for preview) if !item.is_public && !is_creator { return Err(AppError::NotFound); } // Only allow files with Clean scan status to be streamed. // A creator may preview their own Pending/HeldForReview content, but // confirmed-malicious (Quarantined) content is never streamable, not even // by its own creator (Run #21 Security LOW). if item.scan_status == db::FileScanStatus::Quarantined || (item.scan_status != db::FileScanStatus::Clean && !is_creator) { return Err(AppError::NotFound); } // Extract S3 key and duration via content enum (audio or video) let (s3_key, duration_seconds) = match item.content() { ContentData::Audio { audio_s3_key: Some(key), duration_seconds, .. } => (key, duration_seconds), ContentData::Video { video_s3_key: Some(key), duration_seconds, .. } => (key, duration_seconds), _ => return Err(AppError::NotFound), }; // Access control, creators always have access to their own content let item_pricing = pricing::for_item(&item); let is_free = item_pricing.is_free(); if !is_free && !is_creator { if maybe_user.is_none() { return Err(AppError::Unauthorized); } let ctx = pricing::AccessContext { is_creator: false, has_purchased: access.has_purchased, subscription: access.subscription, }; if !item_pricing.can_access(&ctx) && !access.has_bundle_access { return Err(AppError::Forbidden); } } // Clamp defensively before casting i32 → u64: a stray negative value // (legacy row predating migration 133's CHECK constraint, or a buggy // future writer) would underflow to ~u64::MAX and yield a presigned URL // valid for centuries. `.max(0)` + saturating_mul + clamp to 24h floor/ // ceiling produces a sane window regardless of input. let expiry_secs = match duration_seconds { Some(duration) => { let nonneg = duration.max(0) as u64; nonneg.saturating_mul(2).clamp(3600, 86_400) } None => 3600, }; let (stream_url, expires_in) = resolve_content_url(s3.as_ref(), &s3_key, expiry_secs).await?; // Increment total play count (includes replays) db::items::increment_play_count(&db, item_id).await?; // Track unique listeners for authenticated users if let Some(ref user) = maybe_user { let _ = db::items::record_unique_play(&db, user.id, item_id).await; } Ok(Json(StreamUrlResponse { stream_url, expires_in, })) } /// Generate a presigned URL for downloading a version file /// /// GET /api/versions/{version_id}/download /// /// Access control: free items are accessible to anyone, paid items require purchase. #[tracing::instrument(skip_all, name = "storage::version_download", fields(version_id))] pub(super) async fn version_download( State(db): State, State(storage): State, MaybeUserVerified(maybe_user): MaybeUserVerified, Path(version_id): Path, ) -> Result { tracing::Span::current().record("version_id", tracing::field::display(&version_id)); let s3 = storage.require_s3()?; // Fetch version let version = db::versions::get_version_by_id(&db, version_id) .await? .ok_or(AppError::NotFound)?; // Check if version has a file let s3_key = version.s3_key.as_ref().ok_or(AppError::NotFound)?; // Fetch item for access control let item = db::items::get_item_by_id(&db, version.item_id) .await? .ok_or(AppError::NotFound)?; // Single-query access check: ownership, purchase, subscription, bundle let user_id = maybe_user.as_ref().map(|u| u.id); let access = db::items::check_item_access(&db, version.item_id, user_id) .await? .ok_or(AppError::NotFound)?; let is_creator = user_id.is_some_and(|uid| uid == access.owner_id); // Unpublished items are only downloadable by their creator if !item.is_public && !is_creator { return Err(AppError::NotFound); } // Only allow files with Clean scan status to be downloaded. A creator may // preview their own Pending/HeldForReview version, but never a Quarantined // one, not even their own (Run #21 Security LOW). if version.scan_status == db::FileScanStatus::Quarantined || (version.scan_status != db::FileScanStatus::Clean && !is_creator) { return Err(AppError::NotFound); } // Access control, creators always have access to their own content let item_pricing = pricing::for_item(&item); let is_free = item_pricing.is_free(); if !is_free && !is_creator { if maybe_user.is_none() { return Err(AppError::Unauthorized); } let ctx = pricing::AccessContext { is_creator: false, has_purchased: access.has_purchased, subscription: access.subscription, }; if !item_pricing.can_access(&ctx) && !access.has_bundle_access { return Err(AppError::Forbidden); } } let (download_url, expires_in) = resolve_content_url(s3.as_ref(), s3_key, 3600).await?; // Increment per-version and item-level download counts db::versions::increment_download_count(&db, version_id).await?; db::items::increment_item_download_count(&db, version.item_id).await?; // Track per-user download for library "new version" indicators if let Some(ref user) = maybe_user { let _ = db::versions::record_user_download(&db, user.id, version.item_id, version_id).await; } let license_url = if item.license_preset.is_some() { Some(format!("/api/items/{}/license.txt", version.item_id)) } else { None }; Ok(Json(VersionDownloadResponse { download_url, file_name: version.file_name, expires_in, license_url, })) }