Skip to main content

max / makenotwork

19.8 KB · 607 lines History Blame Raw
1 //! Public item detail page handler.
2
3 use axum::{
4 extract::{Path, State},
5 response::{IntoResponse, Response},
6 };
7 use sqlx::PgPool;
8 use tower_sessions::Session;
9
10 use crate::{
11 AppStorage, Integrations,
12 auth::{MaybeUserVerified, SessionUser},
13 config::Config,
14 db::{self, ContentData, ItemId, ItemType},
15 error::{AppError, Result},
16 helpers::{fetch_discussion_info, get_csrf_token, get_initials},
17 pricing,
18 templates::{AudioPlayerTemplate, ItemTemplate, TextReaderTemplate, VideoPlayerTemplate},
19 types::{Item, ItemSection},
20 };
21
22 /// Render a public item detail page (text reader, audio player, or download).
23 #[tracing::instrument(skip_all, name = "content::item_page")]
24 pub(in crate::routes::pages::public) async fn item_page(
25 State(db): State<PgPool>,
26 State(integrations): State<Integrations>,
27 State(config): State<Config>,
28 session: Session,
29 MaybeUserVerified(maybe_user): MaybeUserVerified,
30 Path(item_id): Path<String>,
31 ) -> Result<Response> {
32 let csrf_token = get_csrf_token(&session).await;
33 let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?;
34 let db_item = db::items::get_item_by_id(&db, id)
35 .await?
36 .ok_or(AppError::NotFound)?;
37 let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
38 .await?
39 .ok_or(AppError::NotFound)?;
40 let db_user = db::users::get_user_by_id(&db, db_project.user_id)
41 .await?
42 .ok_or(AppError::NotFound)?;
43 if db_user.is_sandbox {
44 return Err(AppError::NotFound);
45 }
46 // View tracking moved to /l/{id}, consumption is the meaningful signal,
47 // not store-page traffic.
48 render_item_page(
49 &db,
50 &integrations,
51 &config,
52 &db_item,
53 &db_project,
54 &db_user,
55 csrf_token,
56 maybe_user,
57 )
58 .await
59 }
60
61 /// Shared item page renderer, used by both named routes and custom domain fallback.
62 #[allow(clippy::too_many_arguments)]
63 pub(crate) async fn render_item_page(
64 db: &PgPool,
65 integrations: &Integrations,
66 config: &Config,
67 db_item: &db::DbItem,
68 db_project: &db::DbProject,
69 db_user: &db::DbUser,
70 csrf_token: Option<String>,
71 maybe_user: Option<SessionUser>,
72 ) -> Result<Response> {
73 // Visibility check: unpublished items only visible to owner
74 let is_owner = maybe_user
75 .as_ref()
76 .is_some_and(|u| u.id == db_project.user_id);
77 if !db_item.is_public && !is_owner {
78 return Err(AppError::NotFound);
79 }
80 if db_item.deleted_at.is_some() && !is_owner {
81 return Err(AppError::NotFound);
82 }
83
84 let cdn_base = config.cdn_base_url.as_str();
85 // Store page never renders the full article body, that lives on /l/{id}.
86 // Compute a short plain-text excerpt from the raw markdown for the deck.
87 let (excerpt, reading_time) = match db_item.content() {
88 ContentData::Text {
89 body,
90 reading_time_minutes,
91 ..
92 } => (
93 body.as_ref().map(|b| make_excerpt(b, 280)),
94 reading_time_minutes.map(|m| format!("{m} min read")),
95 ),
96 _ => (None, None),
97 };
98
99 let item_pricing = pricing::for_item(db_item);
100 let in_library = if let Some(ref user) = maybe_user {
101 db::transactions::has_purchased_item(db, user.id, db_item.id).await?
102 } else {
103 false
104 };
105 let item_sub = if let Some(ref user) = maybe_user {
106 db::subscriptions::SubscriptionGate::check(
107 db,
108 user.id,
109 db::subscriptions::SubscriptionScope::Item(db_item.id),
110 )
111 .await?
112 } else {
113 None
114 };
115 let ctx = pricing::AccessContext {
116 is_creator: is_owner,
117 has_purchased: in_library,
118 subscription: item_sub,
119 };
120 let mut has_access = item_pricing.can_access(&ctx);
121 let is_free = item_pricing.is_free();
122
123 // Bundle access: user may have purchased a bundle containing this item
124 if !has_access
125 && let Some(ref user) = maybe_user
126 && db::bundles::has_access_via_bundle(db, user.id, db_item.id).await?
127 {
128 has_access = true;
129 }
130
131 // For unlisted items, load the bundles that contain them (for "Available in" display)
132 let containing_bundle_ids = if db_item.listed {
133 vec![]
134 } else {
135 db::bundles::get_bundles_containing_item(db, db_item.id).await?
136 };
137 // Single batched query instead of one get_item_by_id per bundle id (N+1).
138 let containing_bundles: Vec<db::DbItem> =
139 db::items::get_public_items_by_ids(db, &containing_bundle_ids).await?;
140
141 // For bundle-type items, load the child items
142 let bundle_child_items = if db_item.item_type == ItemType::Bundle {
143 db::bundles::get_bundle_items(db, db_item.id).await?
144 } else {
145 vec![]
146 };
147
148 let item_tags = db::tags::get_tags_for_item(db, db_item.id).await?;
149 let item = Item::from_db_detail(
150 db_item,
151 &item_tags,
152 None,
153 reading_time.clone(),
154 is_free,
155 has_access,
156 db_user.settlement_currency,
157 );
158
159 if db_item.item_type == ItemType::Text {
160 let avatar_initials =
161 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
162 let project_slug_str = db_project.slug.to_string();
163 let (discussion_url, discussion_count) = fetch_discussion_info(
164 integrations,
165 config,
166 db_item.mt_thread_id,
167 &project_slug_str,
168 "items",
169 )
170 .await;
171 return Ok(TextReaderTemplate {
172 csrf_token: csrf_token.clone(),
173 session_user: maybe_user,
174 item,
175 creator_username: db_user.username.to_string(),
176 creator_display_name: db_user.display_name.clone(),
177 creator_avatar_initials: avatar_initials,
178 project_title: db_project.title.clone(),
179 project_slug: project_slug_str,
180 is_free,
181 in_library,
182 has_access,
183 reading_time,
184 excerpt,
185 host_url: config.host_url.clone(),
186 discussion_url,
187 discussion_count,
188 }
189 .into_response());
190 }
191
192 if db_item.item_type == ItemType::Audio {
193 let avatar_initials =
194 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
195 let project_slug_str = db_project.slug.to_string();
196 let (discussion_url, discussion_count) = fetch_discussion_info(
197 integrations,
198 config,
199 db_item.mt_thread_id,
200 &project_slug_str,
201 "items",
202 )
203 .await;
204 return Ok(AudioPlayerTemplate {
205 csrf_token: csrf_token.clone(),
206 session_user: maybe_user,
207 item,
208 creator_username: db_user.username.to_string(),
209 creator_display_name: db_user.display_name.clone(),
210 creator_avatar_initials: avatar_initials,
211 project_title: Some(db_project.title.clone()),
212 project_slug: project_slug_str,
213 is_free,
214 in_library,
215 has_access,
216 host_url: config.host_url.clone(),
217 discussion_url,
218 discussion_count,
219 }
220 .into_response());
221 }
222
223 if db_item.item_type == ItemType::Video {
224 let avatar_initials =
225 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
226 let project_slug_str = db_project.slug.to_string();
227 let (discussion_url, discussion_count) = fetch_discussion_info(
228 integrations,
229 config,
230 db_item.mt_thread_id,
231 &project_slug_str,
232 "items",
233 )
234 .await;
235 return Ok(VideoPlayerTemplate {
236 csrf_token: csrf_token.clone(),
237 session_user: maybe_user,
238 item,
239 creator_username: db_user.username.to_string(),
240 creator_display_name: db_user.display_name.clone(),
241 creator_avatar_initials: avatar_initials,
242 project_title: Some(db_project.title.clone()),
243 project_slug: project_slug_str,
244 is_free,
245 in_library,
246 has_access,
247 host_url: config.host_url.clone(),
248 discussion_url,
249 discussion_count,
250 }
251 .into_response());
252 }
253
254 let project_slug_str = db_project.slug.to_string();
255 let (discussion_url, discussion_count) = fetch_discussion_info(
256 integrations,
257 config,
258 db_item.mt_thread_id,
259 &project_slug_str,
260 "items",
261 )
262 .await;
263
264 // Convert bundle child items to view models
265 let bundle_item_views: Vec<Item> = bundle_child_items
266 .iter()
267 .map(|child| {
268 let child_tags = Vec::new(); // Tags not needed for bundle child list display
269 // A bundle's children live in the same project, so one creator and one
270 // currency: the page's.
271 Item::from_db_list(
272 child,
273 &child_tags,
274 child.price_cents == 0,
275 false,
276 db_user.settlement_currency,
277 )
278 })
279 .collect();
280
281 // Convert containing bundles to view models
282 let containing_bundle_views: Vec<Item> = containing_bundles
283 .iter()
284 .map(|b| {
285 let b_tags = Vec::new();
286 Item::from_db_list(
287 b,
288 &b_tags,
289 b.price_cents == 0,
290 false,
291 db_user.settlement_currency,
292 )
293 })
294 .collect();
295
296 let db_sections = db::item_sections::list_by_item(db, db_item.id).await?;
297 let sections: Vec<ItemSection> = db_sections
298 .iter()
299 .map(|s| ItemSection::from_db(s, db_project.user_id, cdn_base))
300 .collect();
301
302 // Wishlist / cart / collection-count for the viewer, collapsed into one query
303 // (was three sequential round-trips). Display-only; a failure falls back to
304 // the empty default, matching the prior per-call error tolerance.
305 let viewer_flags = if let Some(ref user) = maybe_user {
306 db::items::get_viewer_item_flags(db, user.id, db_item.id)
307 .await
308 .unwrap_or_default()
309 } else {
310 db::items::ViewerItemFlags::default()
311 };
312 let is_wishlisted = viewer_flags.is_wishlisted;
313 let in_cart = viewer_flags.in_cart;
314 let collection_count = viewer_flags.collection_count as u32;
315
316 // Ordered gallery → carousel frames (additive to the cover image). Alt is
317 // creator-optional; fall back to a title-based description rather than an
318 // empty string (the carousel relies on alt for screen-reader parity, and
319 // CarouselFrame::new debug-asserts non-empty, so build the struct directly).
320 let gallery = db::gallery_images::list_for_item(db, db_item.id)
321 .await
322 .unwrap_or_default()
323 .into_iter()
324 .map(|g| crate::templates::CarouselFrame {
325 // A creator upload: `gallery_images` records a byte count and never
326 // recorded a size, so there is nothing to reserve with. Filed as
327 // the reason galleries still shift while the landing page does not.
328 intrinsic: None,
329 image: g.image_url,
330 alt: if g.alt.trim().is_empty() {
331 format!("{} gallery image", db_item.title)
332 } else {
333 g.alt
334 },
335 caption: None,
336 })
337 .collect();
338
339 Ok(ItemTemplate {
340 csrf_token,
341 session_user: maybe_user,
342 item,
343 price_currency: db_user.settlement_currency.code_upper(),
344 creator_username: db_user.username.to_string(),
345 project_title: db_project.title.clone(),
346 project_slug: project_slug_str,
347 host_url: config.host_url.clone(),
348 project_cover_image_url: db_project.cover_image_url.clone(),
349 discussion_url,
350 discussion_count,
351 bundle_items: bundle_item_views,
352 containing_bundles: containing_bundle_views,
353 sections,
354 is_owner,
355 is_wishlisted,
356 in_cart,
357 collection_count,
358 has_access,
359 gallery,
360 theme_css: crate::theming::theme_css(db_project.theme_id.as_deref()),
361 }
362 .into_response())
363 }
364
365 // Segment builder for insertion playback
366
367 /// A player segment for the JS segment playlist.
368 #[derive(serde::Serialize)]
369 struct PlayerSegment {
370 url: String,
371 duration_ms: u32,
372 segment_type: String,
373 title: Option<String>,
374 }
375
376 /// Build the segments JSON for the media player. Returns "null" if no insertions.
377 pub(super) async fn build_segments_json(
378 db: &PgPool,
379 storage: &AppStorage,
380 item_id: ItemId,
381 media_url: Option<&String>,
382 db_item: &db::DbItem,
383 ) -> String {
384 let Ok(placements) =
385 db::content_insertions::list_playable_placements_for_item(db, item_id).await
386 else {
387 return "null".to_string();
388 };
389
390 if placements.is_empty() {
391 return "null".to_string();
392 }
393
394 let Some(s3) = &storage.s3 else {
395 return "null".to_string();
396 };
397
398 // Presign each insertion URL
399 let mut segments: Vec<PlayerSegment> = Vec::new();
400 let mut presigned_cache: std::collections::HashMap<String, String> =
401 std::collections::HashMap::new();
402
403 // Collect pre-rolls, mid-rolls (sorted by offset), and post-rolls
404 let mut pre_rolls = Vec::new();
405 let mut mid_rolls = Vec::new();
406 let mut post_rolls = Vec::new();
407
408 for p in &placements {
409 let url = if let Some(cached) = presigned_cache.get(&p.insertion_storage_key) {
410 cached.clone()
411 } else {
412 match s3
413 .presign_download(
414 &crate::storage::S3Key::from_stored(&p.insertion_storage_key),
415 Some(3600),
416 )
417 .await
418 {
419 Ok(url) => {
420 presigned_cache.insert(p.insertion_storage_key.clone(), url.clone());
421 url
422 }
423 Err(_) => continue,
424 }
425 };
426
427 let seg = PlayerSegment {
428 url,
429 duration_ms: p.insertion_duration_ms.max(0) as u32,
430 segment_type: p.position.to_string(),
431 title: Some(p.insertion_title.clone()),
432 };
433
434 match p.position {
435 db::InsertionPosition::PreRoll => pre_rolls.push(seg),
436 db::InsertionPosition::MidRoll => mid_rolls.push((p.offset_ms.unwrap_or(0), seg)),
437 db::InsertionPosition::PostRoll => post_rolls.push(seg),
438 }
439 }
440
441 // Get main content duration in ms
442 let main_duration_ms = match db_item.content() {
443 ContentData::Audio {
444 duration_seconds, ..
445 }
446 | ContentData::Video {
447 duration_seconds, ..
448 } => duration_seconds.map_or(0, |s| (s.max(0) as u64 * 1000).min(u32::MAX as u64) as u32),
449 _ => 0,
450 };
451
452 // Add pre-rolls
453 for seg in pre_rolls {
454 segments.push(seg);
455 }
456
457 // Sort mid-rolls by offset, then interleave with main content segments
458 mid_rolls.sort_by_key(|(offset, _)| *offset);
459
460 if mid_rolls.is_empty() {
461 // No mid-rolls: single main segment
462 if let Some(url) = media_url {
463 segments.push(PlayerSegment {
464 url: url.clone(),
465 duration_ms: main_duration_ms,
466 segment_type: "main".to_string(),
467 title: None,
468 });
469 }
470 } else {
471 // Split main content around mid-roll offsets
472 let mut last_offset_ms: u32 = 0;
473 if let Some(url) = media_url {
474 for (offset_ms, mid_seg) in mid_rolls {
475 let offset = offset_ms.max(0) as u32;
476 if offset > last_offset_ms {
477 // Main segment before this mid-roll
478 segments.push(PlayerSegment {
479 url: url.clone(),
480 duration_ms: offset - last_offset_ms,
481 segment_type: "main".to_string(),
482 title: None,
483 });
484 }
485 segments.push(mid_seg);
486 last_offset_ms = offset;
487 }
488 // Remaining main content after the last mid-roll
489 if last_offset_ms < main_duration_ms {
490 segments.push(PlayerSegment {
491 url: url.clone(),
492 duration_ms: main_duration_ms - last_offset_ms,
493 segment_type: "main".to_string(),
494 title: None,
495 });
496 }
497 }
498 }
499
500 // Add post-rolls
501 for seg in post_rolls {
502 segments.push(seg);
503 }
504
505 let Ok(json) = serde_json::to_string(&segments) else {
506 return "null".to_string();
507 };
508 escape_json_for_script_tag(&json)
509 }
510
511 /// Neutralize `</` so a JSON value can't break out of the surrounding
512 /// `<script>` block (serde_json does not escape `<`, `>`, or `/` by default, and
513 /// the result is emitted through Askama's `|safe` filter). Load-bearing: a
514 /// creator-controlled insertion title containing `</script>` would otherwise
515 /// inject markup. Pinned by `script_tag_breakout_is_neutralized`.
516 fn escape_json_for_script_tag(json: &str) -> String {
517 json.replace("</", "<\\/")
518 }
519
520 /// Build a short plain-text excerpt from raw markdown.
521 ///
522 /// Takes the first paragraph (up to the first blank line), strips obvious
523 /// markdown syntax, collapses whitespace, and truncates at `max_chars` with a
524 /// trailing ellipsis. Used for the store-page deck so visitors can preview a
525 /// paid article without unlocking the full body.
526 fn make_excerpt(body: &str, max_chars: usize) -> String {
527 let first_para = body
528 .split("\n\n")
529 .find(|p| !p.trim().is_empty())
530 .unwrap_or("");
531 let stripped: String = first_para
532 .lines()
533 .map(|line| line.trim_start_matches(['#', '>', '-', '*', ' ']))
534 .collect::<Vec<_>>()
535 .join(" ");
536 let plain: String = stripped
537 .replace(['*', '_', '`', '[', ']'], "")
538 .split_whitespace()
539 .collect::<Vec<_>>()
540 .join(" ");
541 if plain.chars().count() <= max_chars {
542 plain
543 } else {
544 let truncated: String = plain.chars().take(max_chars).collect();
545 format!("{}", truncated.trim_end())
546 }
547 }
548
549 #[cfg(test)]
550 mod tests {
551 use super::{escape_json_for_script_tag, make_excerpt};
552
553 #[test]
554 fn script_tag_breakout_is_neutralized() {
555 // A serialized value carrying `</script>` must not survive verbatim into
556 // the <script type="application/json"> block.
557 let raw = serde_json::to_string("</script><script>alert(1)</script>").unwrap();
558 let escaped = escape_json_for_script_tag(&raw);
559 assert!(
560 !escaped.contains("</script>"),
561 "literal </script> leaked: {escaped}"
562 );
563 assert!(
564 !escaped.contains("</"),
565 "no unescaped </ should remain: {escaped}"
566 );
567 assert!(
568 escaped.contains("<\\/script>"),
569 "expected escaped form: {escaped}"
570 );
571 }
572
573 #[test]
574 fn excerpt_short_passes_through() {
575 assert_eq!(make_excerpt("Hello world", 100), "Hello world");
576 }
577
578 #[test]
579 fn excerpt_first_paragraph_only() {
580 let body = "First paragraph.\n\nSecond paragraph should be ignored.";
581 assert_eq!(make_excerpt(body, 100), "First paragraph.");
582 }
583
584 #[test]
585 fn excerpt_strips_markdown_markers() {
586 let body = "# Heading\n**bold** and *italic* and `code` and [link](url)";
587 let out = make_excerpt(body, 100);
588 assert!(out.contains("Heading"));
589 assert!(out.contains("bold"));
590 assert!(!out.contains("**"));
591 assert!(!out.contains('`'));
592 }
593
594 #[test]
595 fn excerpt_truncates_with_ellipsis() {
596 let body = "a".repeat(500);
597 let out = make_excerpt(&body, 50);
598 assert_eq!(out.chars().count(), 51); // 50 chars + ellipsis
599 assert!(out.ends_with(''));
600 }
601
602 #[test]
603 fn excerpt_empty_body() {
604 assert_eq!(make_excerpt("", 100), "");
605 }
606 }
607