//! Streaming and download handlers for content access. use axum::{ extract::{Path, State}, response::IntoResponse, Json, }; use serde::Serialize; use crate::{ auth::MaybeUserVerified, db::{self, ContentData, ItemId, VersionId}, error::{AppError, Result, ResultExt}, pricing, AppState, }; /// JSON response containing a presigned streaming/download URL. #[derive(Debug, Serialize)] pub struct StreamUrlResponse { pub stream_url: String, pub expires_in: u64, } /// JSON response containing a presigned download URL for a version. #[derive(Debug, Serialize)] pub 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: CDN for free content when configured, presigned S3 otherwise. async fn resolve_content_url( s3: &dyn crate::storage::StorageBackend, cdn_base_url: Option<&str>, s3_key: &str, is_free: bool, expiry_secs: u64, ) -> Result<(String, u64)> { if is_free && let Some(cdn_base) = cdn_base_url { return Ok((format!("{}/{}", cdn_base, s3_key), 0)); } let url = s3.presign_download(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(state): 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 = state.require_s3()?; // Get the item let item = db::items::get_item_by_id(&state.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(&state.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. // Blocks Pending (not yet scanned), Quarantined, and HeldForReview. // Creators can stream their own HeldForReview content for preview. if 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(), state.config.cdn_base_url.as_deref(), &s3_key, is_free, expiry_secs, ).await?; // Increment total play count (includes replays) db::items::increment_play_count(&state.db, item_id).await?; // Track unique listeners for authenticated users if let Some(ref user) = maybe_user { let _ = db::items::record_unique_play(&state.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(state): State, MaybeUserVerified(maybe_user): MaybeUserVerified, Path(version_id): Path, ) -> Result { tracing::Span::current().record("version_id", tracing::field::display(&version_id)); let s3 = state.require_s3()?; // Fetch version let version = db::versions::get_version_by_id(&state.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(&state.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(&state.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 (creators can access their own) if 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(), state.config.cdn_base_url.as_deref(), s3_key, is_free, 3600, ).await?; // Increment per-version and item-level download counts db::versions::increment_download_count(&state.db, version_id).await?; db::items::increment_item_download_count(&state.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(&state.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, })) }