Skip to main content

max / makenotwork

14.4 KB · 461 lines History Blame Raw
1 //! `/l/{item_id}`: library (consumption) view for items the viewer has access to.
2 //!
3 //! Separate from `/i/{id}` (store page). 403 if the viewer doesn't have access;
4 //! 404 if the item is missing, unpublished/deleted, or owned by a sandbox seller.
5
6 use axum::{
7 extract::{Path, State},
8 response::{IntoResponse, Response},
9 };
10 use sqlx::PgPool;
11 use tower_sessions::Session;
12
13 use crate::{
14 AppStorage, Integrations,
15 auth::MaybeUserVerified,
16 config::Config,
17 constants,
18 db::{self, ContentData, ItemId, ItemType},
19 error::{AppError, Result},
20 helpers::{fetch_discussion_info, get_csrf_token, get_initials},
21 pricing,
22 templates::{
23 LibraryAudioTemplate, LibraryDownloadsTemplate, LibraryLockedTemplate, LibraryTextTemplate,
24 LibraryVideoTemplate,
25 },
26 types::{Chapter, Item, ItemSection, Version},
27 };
28
29 /// `GET /l/{item_id}`: render the library (consumption) view.
30 #[allow(clippy::too_many_arguments)]
31 #[tracing::instrument(skip_all, name = "content::library_page")]
32 pub(in crate::routes::pages::public) async fn library_page(
33 State(db): State<PgPool>,
34 State(storage): State<AppStorage>,
35 State(config): State<Config>,
36 State(integrations): State<Integrations>,
37 State(page_view_tx): State<crate::db::page_views::PageViewTx>,
38 session: Session,
39 headers: axum::http::HeaderMap,
40 MaybeUserVerified(maybe_user): MaybeUserVerified,
41 Path(item_id): Path<String>,
42 ) -> Result<Response> {
43 let csrf_token = get_csrf_token(&session).await;
44 let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
45
46 let db_item = db::items::get_item_by_id(&db, id)
47 .await?
48 .ok_or(AppError::NotFound)?;
49 let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
50 .await?
51 .ok_or(AppError::NotFound)?;
52 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
53 .await?
54 .ok_or(AppError::NotFound)?;
55 if db_user.is_sandbox {
56 return Err(AppError::NotFound);
57 }
58
59 let is_owner = maybe_user
60 .as_ref()
61 .is_some_and(|u| u.id == db_project.user_id);
62
63 // Unpublished or soft-deleted items: hide from non-owners (don't leak draft existence).
64 if (!db_item.is_public || db_item.deleted_at.is_some()) && !is_owner {
65 return Err(AppError::NotFound);
66 }
67
68 // Compute access using the same logic as item_page.
69 let item_pricing = pricing::for_item(&db_item);
70 let in_library = if let Some(ref user) = maybe_user {
71 db::transactions::has_purchased_item(&db, user.id, db_item.id).await?
72 } else {
73 false
74 };
75 let item_sub = if let Some(ref user) = maybe_user {
76 db::subscriptions::SubscriptionGate::check(
77 &db,
78 user.id,
79 db::subscriptions::SubscriptionScope::Item(db_item.id),
80 )
81 .await?
82 } else {
83 None
84 };
85 let ctx = pricing::AccessContext {
86 is_creator: is_owner,
87 has_purchased: in_library,
88 subscription: item_sub,
89 };
90 let mut has_access = item_pricing.can_access(&ctx);
91 if !has_access
92 && let Some(ref user) = maybe_user
93 && db::bundles::has_access_via_bundle(&db, user.id, db_item.id).await?
94 {
95 has_access = true;
96 }
97
98 let item_tags = db::tags::get_tags_for_item(&db, db_item.id).await?;
99 let is_free = item_pricing.is_free();
100 let item = Item::from_db_detail(&db_item, &item_tags, None, None, is_free, has_access);
101
102 if !has_access {
103 // Render 403 with link back to /i/{id}. For unlisted items, list containing bundles.
104 let containing_bundles: Vec<Item> = if db_item.listed {
105 Vec::new()
106 } else {
107 let bundle_ids = db::bundles::get_bundles_containing_item(&db, db_item.id).await?;
108 // Batch-fetch the public bundles in one query (Perf-MIN N+1);
109 // get_public_items_by_ids already filters to is_public, matching the
110 // per-item check the old loop did.
111 db::items::get_public_items_by_ids(&db, &bundle_ids)
112 .await?
113 .iter()
114 .map(|b| {
115 let tags = Vec::new();
116 Item::from_db_list(b, &tags, b.price_cents == 0, false)
117 })
118 .collect()
119 };
120
121 let is_logged_in = maybe_user.is_some();
122 return Ok((
123 axum::http::StatusCode::FORBIDDEN,
124 LibraryLockedTemplate {
125 csrf_token,
126 session_user: maybe_user,
127 item,
128 creator_username: db_user.username.to_string(),
129 host_url: config.host_url.clone(),
130 containing_bundles,
131 is_logged_in,
132 },
133 )
134 .into_response());
135 }
136
137 // View tracking belongs on /l/ (consumption signal), not /i/.
138 let ua = headers
139 .get(axum::http::header::USER_AGENT)
140 .and_then(|v| v.to_str().ok())
141 .unwrap_or("");
142 if !super::is_bot(ua) {
143 super::track_view(&page_view_tx, "item", *db_item.id);
144 }
145
146 let db_versions = db::versions::get_versions_by_item(&db, db_item.id).await?;
147 let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
148
149 let project_slug_str = db_project.slug.to_string();
150 let (discussion_url, discussion_count) = fetch_discussion_info(
151 &integrations,
152 &config,
153 db_item.mt_thread_id,
154 &project_slug_str,
155 "items",
156 )
157 .await;
158
159 // Phase 2: audio items get their own player template.
160 if db_item.item_type == ItemType::Audio {
161 return render_audio_library(
162 &db,
163 &storage,
164 &config,
165 &db_item,
166 &db_user,
167 &db_project,
168 csrf_token,
169 maybe_user,
170 item,
171 versions,
172 discussion_url,
173 discussion_count,
174 is_owner,
175 )
176 .await;
177 }
178
179 // Phase 4: text items get their own reader template.
180 if db_item.item_type == ItemType::Text {
181 return render_text_library(
182 &config,
183 &db_item,
184 &db_user,
185 &db_project,
186 csrf_token,
187 maybe_user,
188 item,
189 discussion_url,
190 discussion_count,
191 is_owner,
192 );
193 }
194
195 // Phase 3: video items get their own player template.
196 if db_item.item_type == ItemType::Video {
197 return render_video_library(
198 &db,
199 &storage,
200 &config,
201 &db_item,
202 &db_user,
203 &db_project,
204 csrf_token,
205 maybe_user,
206 item,
207 versions,
208 discussion_url,
209 discussion_count,
210 is_owner,
211 )
212 .await;
213 }
214
215 // Phase 1: downloads / bundle / other items render here. Audio, video, and
216 // text branches above handle their own templates.
217 let bundle_child_items = if db_item.item_type == ItemType::Bundle {
218 db::bundles::get_bundle_items(&db, db_item.id).await?
219 } else {
220 Vec::new()
221 };
222 let bundle_items: Vec<Item> = bundle_child_items
223 .iter()
224 .map(|child| {
225 let child_tags = Vec::new();
226 Item::from_db_list(child, &child_tags, child.price_cents == 0, false)
227 })
228 .collect();
229
230 let cdn_base = config.cdn_base_url.as_str();
231 let db_sections = db::item_sections::list_by_item(&db, db_item.id).await?;
232 let sections: Vec<ItemSection> = db_sections
233 .iter()
234 .map(|s| ItemSection::from_db(s, db_project.user_id, cdn_base))
235 .collect();
236
237 Ok(LibraryDownloadsTemplate {
238 csrf_token,
239 session_user: maybe_user,
240 item,
241 creator_username: db_user.username.to_string(),
242 project_title: db_project.title.clone(),
243 project_slug: project_slug_str,
244 host_url: config.host_url.clone(),
245 versions,
246 bundle_items,
247 sections,
248 discussion_url,
249 discussion_count,
250 is_owner,
251 }
252 .into_response())
253 }
254
255 #[allow(clippy::too_many_arguments)]
256 async fn render_audio_library(
257 db: &PgPool,
258 storage: &AppStorage,
259 config: &Config,
260 db_item: &db::DbItem,
261 db_user: &db::DbUser,
262 db_project: &db::DbProject,
263 csrf_token: Option<String>,
264 maybe_user: Option<crate::auth::SessionUser>,
265 item: Item,
266 versions: Vec<Version>,
267 discussion_url: Option<String>,
268 discussion_count: Option<i64>,
269 is_owner: bool,
270 ) -> Result<Response> {
271 let avatar_initials =
272 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
273 let db_chapters = db::chapters::get_chapters_by_item(db, db_item.id).await?;
274 let chapters: Vec<Chapter> = db_chapters.iter().map(Chapter::from).collect();
275
276 let audio_url = match db_item.content() {
277 ContentData::Audio {
278 audio_s3_key,
279 duration_seconds,
280 audio_url,
281 ..
282 } => {
283 if let (Some(s3_key), Some(s3)) = (&audio_s3_key, &storage.s3) {
284 let expiry_secs = match duration_seconds {
285 Some(duration) => {
286 ((duration as u64) * 2).clamp(3600, constants::STREAMING_CACHE_MAX_SECS)
287 }
288 None => 3600,
289 };
290 match s3
291 .presign_download(
292 &crate::storage::S3Key::from_stored(s3_key),
293 Some(expiry_secs),
294 )
295 .await
296 {
297 Ok(url) => Some(url),
298 Err(e) => {
299 tracing::warn!(s3_key = %s3_key, error = ?e, "failed to generate presigned url");
300 audio_url
301 }
302 }
303 } else {
304 audio_url
305 }
306 }
307 _ => None,
308 };
309
310 let segments_json =
311 super::item::build_segments_json(db, storage, db_item.id, audio_url.as_ref(), db_item)
312 .await;
313
314 Ok(LibraryAudioTemplate {
315 csrf_token,
316 session_user: maybe_user,
317 item,
318 creator_username: db_user.username.to_string(),
319 creator_display_name: db_user.display_name.clone(),
320 creator_avatar_initials: avatar_initials,
321 project_title: Some(db_project.title.clone()),
322 project_slug: db_project.slug.to_string(),
323 audio_url,
324 chapters,
325 segments_json,
326 versions,
327 host_url: config.host_url.clone(),
328 discussion_url,
329 discussion_count,
330 is_owner,
331 }
332 .into_response())
333 }
334
335 #[allow(clippy::too_many_arguments)]
336 async fn render_video_library(
337 db: &PgPool,
338 storage: &AppStorage,
339 config: &Config,
340 db_item: &db::DbItem,
341 db_user: &db::DbUser,
342 db_project: &db::DbProject,
343 csrf_token: Option<String>,
344 maybe_user: Option<crate::auth::SessionUser>,
345 item: Item,
346 versions: Vec<Version>,
347 discussion_url: Option<String>,
348 discussion_count: Option<i64>,
349 is_owner: bool,
350 ) -> Result<Response> {
351 let avatar_initials =
352 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
353 let db_chapters = db::chapters::get_chapters_by_item(db, db_item.id).await?;
354 let chapters: Vec<Chapter> = db_chapters.iter().map(Chapter::from).collect();
355
356 let video_url = match db_item.content() {
357 ContentData::Video {
358 video_s3_key,
359 duration_seconds,
360 ..
361 } => {
362 if let (Some(s3_key), Some(s3)) = (&video_s3_key, &storage.s3) {
363 let expiry_secs = match duration_seconds {
364 Some(duration) => {
365 ((duration as u64) * 2).clamp(3600, constants::STREAMING_CACHE_MAX_SECS)
366 }
367 None => 3600,
368 };
369 match s3
370 .presign_download(
371 &crate::storage::S3Key::from_stored(s3_key),
372 Some(expiry_secs),
373 )
374 .await
375 {
376 Ok(url) => Some(url),
377 Err(e) => {
378 tracing::warn!(s3_key = %s3_key, error = ?e, "failed to generate presigned video url");
379 None
380 }
381 }
382 } else {
383 None
384 }
385 }
386 _ => None,
387 };
388
389 let segments_json =
390 super::item::build_segments_json(db, storage, db_item.id, video_url.as_ref(), db_item)
391 .await;
392
393 Ok(LibraryVideoTemplate {
394 csrf_token,
395 session_user: maybe_user,
396 item,
397 creator_username: db_user.username.to_string(),
398 creator_display_name: db_user.display_name.clone(),
399 creator_avatar_initials: avatar_initials,
400 project_title: Some(db_project.title.clone()),
401 project_slug: db_project.slug.to_string(),
402 video_url,
403 chapters,
404 segments_json,
405 versions,
406 host_url: config.host_url.clone(),
407 discussion_url,
408 discussion_count,
409 is_owner,
410 }
411 .into_response())
412 }
413
414 #[allow(clippy::too_many_arguments)]
415 fn render_text_library(
416 config: &Config,
417 db_item: &db::DbItem,
418 db_user: &db::DbUser,
419 db_project: &db::DbProject,
420 csrf_token: Option<String>,
421 maybe_user: Option<crate::auth::SessionUser>,
422 item: Item,
423 discussion_url: Option<String>,
424 discussion_count: Option<i64>,
425 is_owner: bool,
426 ) -> Result<Response> {
427 let avatar_initials =
428 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
429 let cdn_base = config.cdn_base_url.as_str();
430 let (body_html, reading_time) = match db_item.content() {
431 ContentData::Text {
432 body,
433 reading_time_minutes,
434 ..
435 } => (
436 body.as_ref()
437 .map(|b| crate::markdown::render_creator_markdown(b, db_project.user_id, cdn_base)),
438 reading_time_minutes.map(|m| format!("{m} min read")),
439 ),
440 _ => (None, None),
441 };
442
443 Ok(LibraryTextTemplate {
444 csrf_token,
445 session_user: maybe_user,
446 item,
447 creator_username: db_user.username.to_string(),
448 creator_display_name: db_user.display_name.clone(),
449 creator_avatar_initials: avatar_initials,
450 project_title: db_project.title.clone(),
451 project_slug: db_project.slug.to_string(),
452 body_html,
453 reading_time,
454 host_url: config.host_url.clone(),
455 discussion_url,
456 discussion_count,
457 is_owner,
458 }
459 .into_response())
460 }
461