Skip to main content

max / makenotwork

14.8 KB · 481 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(
101 &db_item,
102 &item_tags,
103 None,
104 None,
105 is_free,
106 has_access,
107 db_user.settlement_currency,
108 );
109
110 if !has_access {
111 // Render 403 with link back to /i/{id}. For unlisted items, list containing bundles.
112 let containing_bundles: Vec<Item> = if db_item.listed {
113 Vec::new()
114 } else {
115 let bundle_ids = db::bundles::get_bundles_containing_item(&db, db_item.id).await?;
116 // Batch-fetch the public bundles in one query (Perf-MIN N+1);
117 // get_public_items_by_ids already filters to is_public, matching the
118 // per-item check the old loop did.
119 db::items::get_public_items_by_ids(&db, &bundle_ids)
120 .await?
121 .iter()
122 .map(|b| {
123 let tags = Vec::new();
124 Item::from_db_list(
125 b,
126 &tags,
127 b.price_cents == 0,
128 false,
129 db_user.settlement_currency,
130 )
131 })
132 .collect()
133 };
134
135 let is_logged_in = maybe_user.is_some();
136 return Ok((
137 axum::http::StatusCode::FORBIDDEN,
138 LibraryLockedTemplate {
139 csrf_token,
140 session_user: maybe_user,
141 item,
142 creator_username: db_user.username.to_string(),
143 host_url: config.host_url.clone(),
144 containing_bundles,
145 is_logged_in,
146 },
147 )
148 .into_response());
149 }
150
151 // View tracking belongs on /l/ (consumption signal), not /i/.
152 let ua = headers
153 .get(axum::http::header::USER_AGENT)
154 .and_then(|v| v.to_str().ok())
155 .unwrap_or("");
156 if !super::is_bot(ua) {
157 super::track_view(&page_view_tx, "item", *db_item.id);
158 }
159
160 let db_versions = db::versions::get_versions_by_item(&db, db_item.id).await?;
161 let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
162
163 let project_slug_str = db_project.slug.to_string();
164 let (discussion_url, discussion_count) = fetch_discussion_info(
165 &integrations,
166 &config,
167 db_item.mt_thread_id,
168 &project_slug_str,
169 "items",
170 )
171 .await;
172
173 // Phase 2: audio items get their own player template.
174 if db_item.item_type == ItemType::Audio {
175 return render_audio_library(
176 &db,
177 &storage,
178 &config,
179 &db_item,
180 &db_user,
181 &db_project,
182 csrf_token,
183 maybe_user,
184 item,
185 versions,
186 discussion_url,
187 discussion_count,
188 is_owner,
189 )
190 .await;
191 }
192
193 // Phase 4: text items get their own reader template.
194 if db_item.item_type == ItemType::Text {
195 return render_text_library(
196 &config,
197 &db_item,
198 &db_user,
199 &db_project,
200 csrf_token,
201 maybe_user,
202 item,
203 discussion_url,
204 discussion_count,
205 is_owner,
206 );
207 }
208
209 // Phase 3: video items get their own player template.
210 if db_item.item_type == ItemType::Video {
211 return render_video_library(
212 &db,
213 &storage,
214 &config,
215 &db_item,
216 &db_user,
217 &db_project,
218 csrf_token,
219 maybe_user,
220 item,
221 versions,
222 discussion_url,
223 discussion_count,
224 is_owner,
225 )
226 .await;
227 }
228
229 // Phase 1: downloads / bundle / other items render here. Audio, video, and
230 // text branches above handle their own templates.
231 let bundle_child_items = if db_item.item_type == ItemType::Bundle {
232 db::bundles::get_bundle_items(&db, db_item.id).await?
233 } else {
234 Vec::new()
235 };
236 let bundle_items: Vec<Item> = bundle_child_items
237 .iter()
238 .map(|child| {
239 let child_tags = Vec::new();
240 Item::from_db_list(
241 child,
242 &child_tags,
243 child.price_cents == 0,
244 false,
245 db_user.settlement_currency,
246 )
247 })
248 .collect();
249
250 let cdn_base = config.cdn_base_url.as_str();
251 let db_sections = db::item_sections::list_by_item(&db, db_item.id).await?;
252 let sections: Vec<ItemSection> = db_sections
253 .iter()
254 .map(|s| ItemSection::from_db(s, db_project.user_id, cdn_base))
255 .collect();
256
257 Ok(LibraryDownloadsTemplate {
258 csrf_token,
259 session_user: maybe_user,
260 item,
261 creator_username: db_user.username.to_string(),
262 project_title: db_project.title.clone(),
263 project_slug: project_slug_str,
264 host_url: config.host_url.clone(),
265 versions,
266 bundle_items,
267 sections,
268 discussion_url,
269 discussion_count,
270 is_owner,
271 }
272 .into_response())
273 }
274
275 #[allow(clippy::too_many_arguments)]
276 async fn render_audio_library(
277 db: &PgPool,
278 storage: &AppStorage,
279 config: &Config,
280 db_item: &db::DbItem,
281 db_user: &db::DbUser,
282 db_project: &db::DbProject,
283 csrf_token: Option<String>,
284 maybe_user: Option<crate::auth::SessionUser>,
285 item: Item,
286 versions: Vec<Version>,
287 discussion_url: Option<String>,
288 discussion_count: Option<i64>,
289 is_owner: bool,
290 ) -> Result<Response> {
291 let avatar_initials =
292 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
293 let db_chapters = db::chapters::get_chapters_by_item(db, db_item.id).await?;
294 let chapters: Vec<Chapter> = db_chapters.iter().map(Chapter::from).collect();
295
296 let audio_url = match db_item.content() {
297 ContentData::Audio {
298 audio_s3_key,
299 duration_seconds,
300 audio_url,
301 ..
302 } => {
303 if let (Some(s3_key), Some(s3)) = (&audio_s3_key, &storage.s3) {
304 let expiry_secs = match duration_seconds {
305 Some(duration) => {
306 ((duration as u64) * 2).clamp(3600, constants::STREAMING_CACHE_MAX_SECS)
307 }
308 None => 3600,
309 };
310 match s3
311 .presign_download(
312 &crate::storage::S3Key::from_stored(s3_key),
313 Some(expiry_secs),
314 )
315 .await
316 {
317 Ok(url) => Some(url),
318 Err(e) => {
319 tracing::warn!(s3_key = %s3_key, error = ?e, "failed to generate presigned url");
320 audio_url
321 }
322 }
323 } else {
324 audio_url
325 }
326 }
327 _ => None,
328 };
329
330 let segments_json =
331 super::item::build_segments_json(db, storage, db_item.id, audio_url.as_ref(), db_item)
332 .await;
333
334 Ok(LibraryAudioTemplate {
335 csrf_token,
336 session_user: maybe_user,
337 item,
338 creator_username: db_user.username.to_string(),
339 creator_display_name: db_user.display_name.clone(),
340 creator_avatar_initials: avatar_initials,
341 project_title: Some(db_project.title.clone()),
342 project_slug: db_project.slug.to_string(),
343 audio_url,
344 chapters,
345 segments_json,
346 versions,
347 host_url: config.host_url.clone(),
348 discussion_url,
349 discussion_count,
350 is_owner,
351 }
352 .into_response())
353 }
354
355 #[allow(clippy::too_many_arguments)]
356 async fn render_video_library(
357 db: &PgPool,
358 storage: &AppStorage,
359 config: &Config,
360 db_item: &db::DbItem,
361 db_user: &db::DbUser,
362 db_project: &db::DbProject,
363 csrf_token: Option<String>,
364 maybe_user: Option<crate::auth::SessionUser>,
365 item: Item,
366 versions: Vec<Version>,
367 discussion_url: Option<String>,
368 discussion_count: Option<i64>,
369 is_owner: bool,
370 ) -> Result<Response> {
371 let avatar_initials =
372 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
373 let db_chapters = db::chapters::get_chapters_by_item(db, db_item.id).await?;
374 let chapters: Vec<Chapter> = db_chapters.iter().map(Chapter::from).collect();
375
376 let video_url = match db_item.content() {
377 ContentData::Video {
378 video_s3_key,
379 duration_seconds,
380 ..
381 } => {
382 if let (Some(s3_key), Some(s3)) = (&video_s3_key, &storage.s3) {
383 let expiry_secs = match duration_seconds {
384 Some(duration) => {
385 ((duration as u64) * 2).clamp(3600, constants::STREAMING_CACHE_MAX_SECS)
386 }
387 None => 3600,
388 };
389 match s3
390 .presign_download(
391 &crate::storage::S3Key::from_stored(s3_key),
392 Some(expiry_secs),
393 )
394 .await
395 {
396 Ok(url) => Some(url),
397 Err(e) => {
398 tracing::warn!(s3_key = %s3_key, error = ?e, "failed to generate presigned video url");
399 None
400 }
401 }
402 } else {
403 None
404 }
405 }
406 _ => None,
407 };
408
409 let segments_json =
410 super::item::build_segments_json(db, storage, db_item.id, video_url.as_ref(), db_item)
411 .await;
412
413 Ok(LibraryVideoTemplate {
414 csrf_token,
415 session_user: maybe_user,
416 item,
417 creator_username: db_user.username.to_string(),
418 creator_display_name: db_user.display_name.clone(),
419 creator_avatar_initials: avatar_initials,
420 project_title: Some(db_project.title.clone()),
421 project_slug: db_project.slug.to_string(),
422 video_url,
423 chapters,
424 segments_json,
425 versions,
426 host_url: config.host_url.clone(),
427 discussion_url,
428 discussion_count,
429 is_owner,
430 }
431 .into_response())
432 }
433
434 #[allow(clippy::too_many_arguments)]
435 fn render_text_library(
436 config: &Config,
437 db_item: &db::DbItem,
438 db_user: &db::DbUser,
439 db_project: &db::DbProject,
440 csrf_token: Option<String>,
441 maybe_user: Option<crate::auth::SessionUser>,
442 item: Item,
443 discussion_url: Option<String>,
444 discussion_count: Option<i64>,
445 is_owner: bool,
446 ) -> Result<Response> {
447 let avatar_initials =
448 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
449 let cdn_base = config.cdn_base_url.as_str();
450 let (body_html, reading_time) = match db_item.content() {
451 ContentData::Text {
452 body,
453 reading_time_minutes,
454 ..
455 } => (
456 body.as_ref()
457 .map(|b| crate::markdown::render_creator_markdown(b, db_project.user_id, cdn_base)),
458 reading_time_minutes.map(|m| format!("{m} min read")),
459 ),
460 _ => (None, None),
461 };
462
463 Ok(LibraryTextTemplate {
464 csrf_token,
465 session_user: maybe_user,
466 item,
467 creator_username: db_user.username.to_string(),
468 creator_display_name: db_user.display_name.clone(),
469 creator_avatar_initials: avatar_initials,
470 project_title: db_project.title.clone(),
471 project_slug: db_project.slug.to_string(),
472 body_html,
473 reading_time,
474 host_url: config.host_url.clone(),
475 discussion_url,
476 discussion_count,
477 is_owner,
478 }
479 .into_response())
480 }
481