Skip to main content

max / makenotwork

9.3 KB · 250 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, Redirect},
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 /// Resolve a content URL for downloadable media. Always a presigned, expiring
27 /// URL, even for free content.
28 ///
29 /// Downloadable media (audio/video/downloads) lives ONLY in the private bucket;
30 /// it is never served unsigned from the CDN. Free content previously resolved to
31 /// a permanent unsigned `{cdn}/{key}` URL, but that (a) required the media bucket
32 /// to be publicly readable, which would expose paid content sharing the same
33 /// bucket, and (b) made free->paid revocation impossible (the URL never
34 /// expired). Presigning free media closes both: the private bucket stays private
35 /// and a short-lived URL self-revokes. Only immutably-public IMAGE content
36 /// (covers/gallery) is served unsigned, and that lives in the separate public
37 /// bucket behind the CDN.
38 async fn resolve_content_url(
39 s3: &dyn crate::storage::StorageBackend,
40 s3_key: &str,
41 expiry_secs: u64,
42 ) -> Result<(String, u64)> {
43 let url = s3
44 .presign_download(
45 &crate::storage::S3Key::from_stored(s3_key),
46 Some(expiry_secs),
47 )
48 .await
49 .context("presign download for content")?;
50 Ok((url, expiry_secs))
51 }
52
53 /// Generate a presigned URL for streaming/downloading content
54 ///
55 /// GET /api/stream/{item_id}
56 ///
57 /// Access control:
58 /// - Free items: Anyone can access
59 /// - Paid items: Must be logged in and have purchased the item
60 #[tracing::instrument(skip_all, name = "storage::stream_url", fields(item_id))]
61 pub(super) async fn stream_url(
62 State(db): State<PgPool>,
63 State(storage): State<AppStorage>,
64 MaybeUserVerified(maybe_user): MaybeUserVerified,
65 Path(item_id): Path<ItemId>,
66 ) -> Result<impl IntoResponse> {
67 tracing::Span::current().record("item_id", tracing::field::display(&item_id));
68
69 let s3 = storage.require_s3()?;
70
71 let item = db::items::get_item_by_id(&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(&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 // A creator may preview their own Pending/HeldForReview content, but
89 // confirmed-malicious (Quarantined) content is never streamable, not even
90 // by its own creator (Run #21 Security LOW).
91 if item.scan_status == db::FileScanStatus::Quarantined
92 || (item.scan_status != db::FileScanStatus::Clean && !is_creator)
93 {
94 return Err(AppError::NotFound);
95 }
96
97 // Extract S3 key and duration via content enum (audio or video)
98 let (s3_key, duration_seconds) = match item.content() {
99 ContentData::Audio {
100 audio_s3_key: Some(key),
101 duration_seconds,
102 ..
103 } => (key, duration_seconds),
104 ContentData::Video {
105 video_s3_key: Some(key),
106 duration_seconds,
107 ..
108 } => (key, duration_seconds),
109 _ => return Err(AppError::NotFound),
110 };
111
112 // Access control, creators always have access to their own content
113 let item_pricing = pricing::for_item(&item);
114 let is_free = item_pricing.is_free();
115
116 if !is_free && !is_creator {
117 if maybe_user.is_none() {
118 return Err(AppError::Unauthorized);
119 }
120 let ctx = pricing::AccessContext {
121 is_creator: false,
122 has_purchased: access.has_purchased,
123 subscription: access.subscription,
124 };
125 if !item_pricing.can_access(&ctx) && !access.has_bundle_access {
126 return Err(AppError::Forbidden);
127 }
128 }
129
130 // Clamp defensively before casting i32 → u64: a stray negative value
131 // (legacy row predating migration 133's CHECK constraint, or a buggy
132 // future writer) would underflow to ~u64::MAX and yield a presigned URL
133 // valid for centuries. `.max(0)` + saturating_mul + clamp to 24h floor/
134 // ceiling produces a sane window regardless of input.
135 let expiry_secs = match duration_seconds {
136 Some(duration) => {
137 let nonneg = duration.max(0) as u64;
138 nonneg.saturating_mul(2).clamp(3600, 86_400)
139 }
140 None => 3600,
141 };
142 let (stream_url, expires_in) = resolve_content_url(s3.as_ref(), &s3_key, expiry_secs).await?;
143
144 // Increment total play count (includes replays)
145 db::items::increment_play_count(&db, item_id).await?;
146
147 // Track unique listeners for authenticated users
148 if let Some(ref user) = maybe_user {
149 let _ = db::items::record_unique_play(&db, user.id, item_id).await;
150 }
151
152 Ok(Json(StreamUrlResponse {
153 stream_url,
154 expires_in,
155 }))
156 }
157
158 /// Redirect to a presigned URL for downloading a version file
159 ///
160 /// GET /api/versions/{version_id}/download
161 ///
162 /// Access control: free items are accessible to anyone, paid items require purchase.
163 ///
164 /// Answers **303 See Other** to the presigned URL rather than JSON describing it.
165 /// A described `Action::get` sends the reader wherever the route sends them, so
166 /// a JSON body would make this control the one thing on the files tab that
167 /// cannot be said in the description layer. `download_url` is the only field
168 /// anyone consumed, and a redirect is it.
169 ///
170 /// A redirect is a webview fact, so a terminal or egui host cannot perform it.
171 /// Serving this route through the description layer so it can answer
172 /// `Outcome::Goto` with a `Destination::External` remains the cross-host spelling
173 /// and stays available. It waited on the flip chain when (a) was chosen; that
174 /// chain has since landed and `64b33b26` deleted the switch, so what it waits on
175 /// now is the files tab converting.
176 #[tracing::instrument(skip_all, name = "storage::version_download", fields(version_id))]
177 pub(super) async fn version_download(
178 State(db): State<PgPool>,
179 State(storage): State<AppStorage>,
180 MaybeUserVerified(maybe_user): MaybeUserVerified,
181 Path(version_id): Path<VersionId>,
182 ) -> Result<impl IntoResponse> {
183 tracing::Span::current().record("version_id", tracing::field::display(&version_id));
184 let s3 = storage.require_s3()?;
185
186 let version = db::versions::get_version_by_id(&db, version_id)
187 .await?
188 .ok_or(AppError::NotFound)?;
189
190 let s3_key = version.s3_key.as_ref().ok_or(AppError::NotFound)?;
191
192 let item = db::items::get_item_by_id(&db, version.item_id)
193 .await?
194 .ok_or(AppError::NotFound)?;
195
196 // Single-query access check: ownership, purchase, subscription, bundle
197 let user_id = maybe_user.as_ref().map(|u| u.id);
198 let access = db::items::check_item_access(&db, version.item_id, user_id)
199 .await?
200 .ok_or(AppError::NotFound)?;
201 let is_creator = user_id.is_some_and(|uid| uid == access.owner_id);
202
203 // Unpublished items are only downloadable by their creator
204 if !item.is_public && !is_creator {
205 return Err(AppError::NotFound);
206 }
207
208 // Only allow files with Clean scan status to be downloaded. A creator may
209 // preview their own Pending/HeldForReview version, but never a Quarantined
210 // one, not even their own (Run #21 Security LOW).
211 if version.scan_status == db::FileScanStatus::Quarantined
212 || (version.scan_status != db::FileScanStatus::Clean && !is_creator)
213 {
214 return Err(AppError::NotFound);
215 }
216
217 // Access control, creators always have access to their own content
218 let item_pricing = pricing::for_item(&item);
219 let is_free = item_pricing.is_free();
220 if !is_free && !is_creator {
221 if maybe_user.is_none() {
222 return Err(AppError::Unauthorized);
223 }
224 let ctx = pricing::AccessContext {
225 is_creator: false,
226 has_purchased: access.has_purchased,
227 subscription: access.subscription,
228 };
229 if !item_pricing.can_access(&ctx) && !access.has_bundle_access {
230 return Err(AppError::Forbidden);
231 }
232 }
233
234 let (download_url, _expires_in) = resolve_content_url(s3.as_ref(), s3_key, 3600).await?;
235
236 // Increment per-version and item-level download counts
237 db::versions::increment_download_count(&db, version_id).await?;
238 db::items::increment_item_download_count(&db, version.item_id).await?;
239
240 // Track per-user download for library "new version" indicators
241 if let Some(ref user) = maybe_user {
242 let _ = db::versions::record_user_download(&db, user.id, version.item_id, version_id).await;
243 }
244
245 // The counters above run before the redirect for the same reason they ran
246 // before the JSON: this handler is the only place the intent to download is
247 // observable. Once the reader is at the storage host we never hear about it.
248 Ok(Redirect::to(&download_url))
249 }
250