Skip to main content

max / makenotwork

8.2 KB · 239 lines History Blame Raw
1 //! Streaming and download handlers for content access.
2
3 use axum::{
4 extract::{Path, State},
5 response::IntoResponse,
6 Json,
7 };
8 use serde::Serialize;
9
10 use crate::{
11 auth::MaybeUserVerified,
12 db::{self, ContentData, ItemId, VersionId},
13 error::{AppError, Result, ResultExt},
14 pricing,
15 AppState,
16 };
17
18 /// JSON response containing a presigned streaming/download URL.
19 #[derive(Debug, Serialize)]
20 pub struct StreamUrlResponse {
21 pub stream_url: String,
22 pub expires_in: u64,
23 }
24
25 /// JSON response containing a presigned download URL for a version.
26 #[derive(Debug, Serialize)]
27 pub struct VersionDownloadResponse {
28 pub download_url: String,
29 pub file_name: Option<String>,
30 pub expires_in: u64,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub license_url: Option<String>,
33 }
34
35 /// Resolve a content URL: CDN for free content when configured, presigned S3 otherwise.
36 async fn resolve_content_url(
37 s3: &dyn crate::storage::StorageBackend,
38 cdn_base_url: Option<&str>,
39 s3_key: &str,
40 is_free: bool,
41 expiry_secs: u64,
42 ) -> Result<(String, u64)> {
43 if is_free && let Some(cdn_base) = cdn_base_url {
44 return Ok((format!("{}/{}", cdn_base, s3_key), 0));
45 }
46 let url = s3.presign_download(s3_key, Some(expiry_secs))
47 .await
48 .context("presign download for content")?;
49 Ok((url, expiry_secs))
50 }
51
52 /// Generate a presigned URL for streaming/downloading content
53 ///
54 /// GET /api/stream/{item_id}
55 ///
56 /// Access control:
57 /// - Free items: Anyone can access
58 /// - Paid items: Must be logged in and have purchased the item
59 #[tracing::instrument(skip_all, name = "storage::stream_url", fields(item_id))]
60 pub(super) async fn stream_url(
61 State(state): State<AppState>,
62 MaybeUserVerified(maybe_user): MaybeUserVerified,
63 Path(item_id): Path<ItemId>,
64 ) -> Result<impl IntoResponse> {
65 tracing::Span::current().record("item_id", tracing::field::display(&item_id));
66
67 // Check if S3 is configured
68 let s3 = state.require_s3()?;
69
70 // Get the item
71 let item = db::items::get_item_by_id(&state.db, item_id)
72 .await?
73 .ok_or(AppError::NotFound)?;
74
75 // Single-query access check: ownership, purchase, subscription, bundle
76 let user_id = maybe_user.as_ref().map(|u| u.id);
77 let access = db::items::check_item_access(&state.db, item_id, user_id)
78 .await?
79 .ok_or(AppError::NotFound)?;
80 let is_creator = user_id.is_some_and(|uid| uid == access.owner_id);
81
82 // Draft items can only be streamed by their creator (for preview)
83 if !item.is_public && !is_creator {
84 return Err(AppError::NotFound);
85 }
86
87 // Only allow files with Clean scan status to be streamed.
88 // Blocks Pending (not yet scanned), Quarantined, and HeldForReview.
89 // Creators can stream their own HeldForReview content for preview.
90 if item.scan_status != db::FileScanStatus::Clean && !is_creator {
91 return Err(AppError::NotFound);
92 }
93
94 // Extract S3 key and duration via content enum (audio or video)
95 let (s3_key, duration_seconds) = match item.content() {
96 ContentData::Audio { audio_s3_key: Some(key), duration_seconds, .. } => (key, duration_seconds),
97 ContentData::Video { video_s3_key: Some(key), duration_seconds, .. } => (key, duration_seconds),
98 _ => return Err(AppError::NotFound),
99 };
100
101 // Access control — creators always have access to their own content
102 let item_pricing = pricing::for_item(&item);
103 let is_free = item_pricing.is_free();
104
105 if !is_free && !is_creator {
106 if maybe_user.is_none() {
107 return Err(AppError::Unauthorized);
108 }
109 let ctx = pricing::AccessContext {
110 is_creator: false,
111 has_purchased: access.has_purchased,
112 subscription: access.subscription,
113 };
114 if !item_pricing.can_access(&ctx) && !access.has_bundle_access {
115 return Err(AppError::Forbidden);
116 }
117 }
118
119 // Clamp defensively before casting i32 → u64: a stray negative value
120 // (legacy row predating migration 133's CHECK constraint, or a buggy
121 // future writer) would underflow to ~u64::MAX and yield a presigned URL
122 // valid for centuries. `.max(0)` + saturating_mul + clamp to 24h floor/
123 // ceiling produces a sane window regardless of input.
124 let expiry_secs = match duration_seconds {
125 Some(duration) => {
126 let nonneg = duration.max(0) as u64;
127 nonneg.saturating_mul(2).clamp(3600, 86_400)
128 }
129 None => 3600,
130 };
131 let (stream_url, expires_in) = resolve_content_url(
132 s3.as_ref(), state.config.cdn_base_url.as_deref(), &s3_key, is_free, expiry_secs,
133 ).await?;
134
135 // Increment total play count (includes replays)
136 db::items::increment_play_count(&state.db, item_id).await?;
137
138 // Track unique listeners for authenticated users
139 if let Some(ref user) = maybe_user {
140 let _ = db::items::record_unique_play(&state.db, user.id, item_id).await;
141 }
142
143 Ok(Json(StreamUrlResponse {
144 stream_url,
145 expires_in,
146 }))
147 }
148
149 /// Generate a presigned URL for downloading a version file
150 ///
151 /// GET /api/versions/{version_id}/download
152 ///
153 /// Access control: free items are accessible to anyone, paid items require purchase.
154 #[tracing::instrument(skip_all, name = "storage::version_download", fields(version_id))]
155 pub(super) async fn version_download(
156 State(state): State<AppState>,
157 MaybeUserVerified(maybe_user): MaybeUserVerified,
158 Path(version_id): Path<VersionId>,
159 ) -> Result<impl IntoResponse> {
160 tracing::Span::current().record("version_id", tracing::field::display(&version_id));
161 let s3 = state.require_s3()?;
162
163 // Fetch version
164 let version = db::versions::get_version_by_id(&state.db, version_id)
165 .await?
166 .ok_or(AppError::NotFound)?;
167
168 // Check if version has a file
169 let s3_key = version
170 .s3_key
171 .as_ref()
172 .ok_or(AppError::NotFound)?;
173
174 // Fetch item for access control
175 let item = db::items::get_item_by_id(&state.db, version.item_id)
176 .await?
177 .ok_or(AppError::NotFound)?;
178
179 // Single-query access check: ownership, purchase, subscription, bundle
180 let user_id = maybe_user.as_ref().map(|u| u.id);
181 let access = db::items::check_item_access(&state.db, version.item_id, user_id)
182 .await?
183 .ok_or(AppError::NotFound)?;
184 let is_creator = user_id.is_some_and(|uid| uid == access.owner_id);
185
186 // Unpublished items are only downloadable by their creator
187 if !item.is_public && !is_creator {
188 return Err(AppError::NotFound);
189 }
190
191 // Only allow files with Clean scan status to be downloaded (creators can access their own)
192 if version.scan_status != db::FileScanStatus::Clean && !is_creator {
193 return Err(AppError::NotFound);
194 }
195
196 // Access control — creators always have access to their own content
197 let item_pricing = pricing::for_item(&item);
198 let is_free = item_pricing.is_free();
199 if !is_free && !is_creator {
200 if maybe_user.is_none() {
201 return Err(AppError::Unauthorized);
202 }
203 let ctx = pricing::AccessContext {
204 is_creator: false,
205 has_purchased: access.has_purchased,
206 subscription: access.subscription,
207 };
208 if !item_pricing.can_access(&ctx) && !access.has_bundle_access {
209 return Err(AppError::Forbidden);
210 }
211 }
212
213 let (download_url, expires_in) = resolve_content_url(
214 s3.as_ref(), state.config.cdn_base_url.as_deref(), s3_key, is_free, 3600,
215 ).await?;
216
217 // Increment per-version and item-level download counts
218 db::versions::increment_download_count(&state.db, version_id).await?;
219 db::items::increment_item_download_count(&state.db, version.item_id).await?;
220
221 // Track per-user download for library "new version" indicators
222 if let Some(ref user) = maybe_user {
223 let _ = db::versions::record_user_download(&state.db, user.id, version.item_id, version_id).await;
224 }
225
226 let license_url = if item.license_preset.is_some() {
227 Some(format!("/api/items/{}/license.txt", version.item_id))
228 } else {
229 None
230 };
231
232 Ok(Json(VersionDownloadResponse {
233 download_url,
234 file_name: version.file_name,
235 expires_in,
236 license_url,
237 }))
238 }
239