//! Streaming and download handlers for content access. use axum::{ Json, extract::{Path, State}, response::{IntoResponse, Redirect}, }; 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, } /// 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)); 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, })) } /// Redirect to 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. /// /// Answers **303 See Other** to the presigned URL rather than JSON describing it. /// Ruled 2026-08-26 (`8fc6b1af`, option (a)): a described `Action::get` sends the /// reader wherever the route sends them, so a JSON body made this control the one /// thing on the files tab that could not be said in the description layer. /// /// Three fields retired with the JSON, all measured unread: `file_name` (every /// surface already renders it server-side from `version.file_name`), `license_url` /// (no reader anywhere in the tree) and `expires_in` (the expiry is 3600 either /// way). `download_url` was the only one anyone consumed, and a redirect *is* it. /// /// A redirect is a webview fact, so a terminal or egui host cannot perform it. /// Serving this route through the description layer so it can answer /// `Outcome::Goto` with a `Destination::External` remains the cross-host spelling /// and stays available. It waited on the flip chain when (a) was chosen; that /// chain has since landed and `64b33b26` deleted the switch, so what it waits on /// now is the files tab converting. #[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()?; let version = db::versions::get_version_by_id(&db, version_id) .await? .ok_or(AppError::NotFound)?; let s3_key = version.s3_key.as_ref().ok_or(AppError::NotFound)?; 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; } // The counters above run before the redirect for the same reason they ran // before the JSON: this handler is the only place the intent to download is // observable. Once the reader is at the storage host we never hear about it. Ok(Redirect::to(&download_url)) }