Skip to main content

max / makenotwork

8.9 KB · 255 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 let s3 = storage.require_s3()?;
80
81 let item = db::items::get_item_by_id(&db, item_id)
82 .await?
83 .ok_or(AppError::NotFound)?;
84
85 // Single-query access check: ownership, purchase, subscription, bundle
86 let user_id = maybe_user.as_ref().map(|u| u.id);
87 let access = db::items::check_item_access(&db, item_id, user_id)
88 .await?
89 .ok_or(AppError::NotFound)?;
90 let is_creator = user_id.is_some_and(|uid| uid == access.owner_id);
91
92 // Draft items can only be streamed by their creator (for preview)
93 if !item.is_public && !is_creator {
94 return Err(AppError::NotFound);
95 }
96
97 // Only allow files with Clean scan status to be streamed.
98 // A creator may preview their own Pending/HeldForReview content, but
99 // confirmed-malicious (Quarantined) content is never streamable, not even
100 // by its own creator (Run #21 Security LOW).
101 if item.scan_status == db::FileScanStatus::Quarantined
102 || (item.scan_status != db::FileScanStatus::Clean && !is_creator)
103 {
104 return Err(AppError::NotFound);
105 }
106
107 // Extract S3 key and duration via content enum (audio or video)
108 let (s3_key, duration_seconds) = match item.content() {
109 ContentData::Audio {
110 audio_s3_key: Some(key),
111 duration_seconds,
112 ..
113 } => (key, duration_seconds),
114 ContentData::Video {
115 video_s3_key: Some(key),
116 duration_seconds,
117 ..
118 } => (key, duration_seconds),
119 _ => return Err(AppError::NotFound),
120 };
121
122 // Access control, creators always have access to their own content
123 let item_pricing = pricing::for_item(&item);
124 let is_free = item_pricing.is_free();
125
126 if !is_free && !is_creator {
127 if maybe_user.is_none() {
128 return Err(AppError::Unauthorized);
129 }
130 let ctx = pricing::AccessContext {
131 is_creator: false,
132 has_purchased: access.has_purchased,
133 subscription: access.subscription,
134 };
135 if !item_pricing.can_access(&ctx) && !access.has_bundle_access {
136 return Err(AppError::Forbidden);
137 }
138 }
139
140 // Clamp defensively before casting i32 → u64: a stray negative value
141 // (legacy row predating migration 133's CHECK constraint, or a buggy
142 // future writer) would underflow to ~u64::MAX and yield a presigned URL
143 // valid for centuries. `.max(0)` + saturating_mul + clamp to 24h floor/
144 // ceiling produces a sane window regardless of input.
145 let expiry_secs = match duration_seconds {
146 Some(duration) => {
147 let nonneg = duration.max(0) as u64;
148 nonneg.saturating_mul(2).clamp(3600, 86_400)
149 }
150 None => 3600,
151 };
152 let (stream_url, expires_in) = resolve_content_url(s3.as_ref(), &s3_key, expiry_secs).await?;
153
154 // Increment total play count (includes replays)
155 db::items::increment_play_count(&db, item_id).await?;
156
157 // Track unique listeners for authenticated users
158 if let Some(ref user) = maybe_user {
159 let _ = db::items::record_unique_play(&db, user.id, item_id).await;
160 }
161
162 Ok(Json(StreamUrlResponse {
163 stream_url,
164 expires_in,
165 }))
166 }
167
168 /// Generate a presigned URL for downloading a version file
169 ///
170 /// GET /api/versions/{version_id}/download
171 ///
172 /// Access control: free items are accessible to anyone, paid items require purchase.
173 #[tracing::instrument(skip_all, name = "storage::version_download", fields(version_id))]
174 pub(super) async fn version_download(
175 State(db): State<PgPool>,
176 State(storage): State<AppStorage>,
177 MaybeUserVerified(maybe_user): MaybeUserVerified,
178 Path(version_id): Path<VersionId>,
179 ) -> Result<impl IntoResponse> {
180 tracing::Span::current().record("version_id", tracing::field::display(&version_id));
181 let s3 = storage.require_s3()?;
182
183 let version = db::versions::get_version_by_id(&db, version_id)
184 .await?
185 .ok_or(AppError::NotFound)?;
186
187 let s3_key = version.s3_key.as_ref().ok_or(AppError::NotFound)?;
188
189 let item = db::items::get_item_by_id(&db, version.item_id)
190 .await?
191 .ok_or(AppError::NotFound)?;
192
193 // Single-query access check: ownership, purchase, subscription, bundle
194 let user_id = maybe_user.as_ref().map(|u| u.id);
195 let access = db::items::check_item_access(&db, version.item_id, user_id)
196 .await?
197 .ok_or(AppError::NotFound)?;
198 let is_creator = user_id.is_some_and(|uid| uid == access.owner_id);
199
200 // Unpublished items are only downloadable by their creator
201 if !item.is_public && !is_creator {
202 return Err(AppError::NotFound);
203 }
204
205 // Only allow files with Clean scan status to be downloaded. A creator may
206 // preview their own Pending/HeldForReview version, but never a Quarantined
207 // one, not even their own (Run #21 Security LOW).
208 if version.scan_status == db::FileScanStatus::Quarantined
209 || (version.scan_status != db::FileScanStatus::Clean && !is_creator)
210 {
211 return Err(AppError::NotFound);
212 }
213
214 // Access control, creators always have access to their own content
215 let item_pricing = pricing::for_item(&item);
216 let is_free = item_pricing.is_free();
217 if !is_free && !is_creator {
218 if maybe_user.is_none() {
219 return Err(AppError::Unauthorized);
220 }
221 let ctx = pricing::AccessContext {
222 is_creator: false,
223 has_purchased: access.has_purchased,
224 subscription: access.subscription,
225 };
226 if !item_pricing.can_access(&ctx) && !access.has_bundle_access {
227 return Err(AppError::Forbidden);
228 }
229 }
230
231 let (download_url, expires_in) = resolve_content_url(s3.as_ref(), s3_key, 3600).await?;
232
233 // Increment per-version and item-level download counts
234 db::versions::increment_download_count(&db, version_id).await?;
235 db::items::increment_item_download_count(&db, version.item_id).await?;
236
237 // Track per-user download for library "new version" indicators
238 if let Some(ref user) = maybe_user {
239 let _ = db::versions::record_user_download(&db, user.id, version.item_id, version_id).await;
240 }
241
242 let license_url = if item.license_preset.is_some() {
243 Some(format!("/api/items/{}/license.txt", version.item_id))
244 } else {
245 None
246 };
247
248 Ok(Json(VersionDownloadResponse {
249 download_url,
250 file_name: version.file_name,
251 expires_in,
252 license_url,
253 }))
254 }
255