Skip to main content

max / makenotwork

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