Skip to main content

max / makenotwork

19.1 KB · 587 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 );
157
158 if db_item.item_type == ItemType::Text {
159 let avatar_initials =
160 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
161 let project_slug_str = db_project.slug.to_string();
162 let (discussion_url, discussion_count) = fetch_discussion_info(
163 integrations,
164 config,
165 db_item.mt_thread_id,
166 &project_slug_str,
167 "items",
168 )
169 .await;
170 return Ok(TextReaderTemplate {
171 csrf_token: csrf_token.clone(),
172 session_user: maybe_user,
173 item,
174 creator_username: db_user.username.to_string(),
175 creator_display_name: db_user.display_name.clone(),
176 creator_avatar_initials: avatar_initials,
177 project_title: db_project.title.clone(),
178 project_slug: project_slug_str,
179 is_free,
180 in_library,
181 has_access,
182 reading_time,
183 excerpt,
184 host_url: config.host_url.clone(),
185 discussion_url,
186 discussion_count,
187 }
188 .into_response());
189 }
190
191 if db_item.item_type == ItemType::Audio {
192 let avatar_initials =
193 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
194 let project_slug_str = db_project.slug.to_string();
195 let (discussion_url, discussion_count) = fetch_discussion_info(
196 integrations,
197 config,
198 db_item.mt_thread_id,
199 &project_slug_str,
200 "items",
201 )
202 .await;
203 return Ok(AudioPlayerTemplate {
204 csrf_token: csrf_token.clone(),
205 session_user: maybe_user,
206 item,
207 creator_username: db_user.username.to_string(),
208 creator_display_name: db_user.display_name.clone(),
209 creator_avatar_initials: avatar_initials,
210 project_title: Some(db_project.title.clone()),
211 project_slug: project_slug_str,
212 is_free,
213 in_library,
214 has_access,
215 host_url: config.host_url.clone(),
216 discussion_url,
217 discussion_count,
218 }
219 .into_response());
220 }
221
222 if db_item.item_type == ItemType::Video {
223 let avatar_initials =
224 get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username));
225 let project_slug_str = db_project.slug.to_string();
226 let (discussion_url, discussion_count) = fetch_discussion_info(
227 integrations,
228 config,
229 db_item.mt_thread_id,
230 &project_slug_str,
231 "items",
232 )
233 .await;
234 return Ok(VideoPlayerTemplate {
235 csrf_token: csrf_token.clone(),
236 session_user: maybe_user,
237 item,
238 creator_username: db_user.username.to_string(),
239 creator_display_name: db_user.display_name.clone(),
240 creator_avatar_initials: avatar_initials,
241 project_title: Some(db_project.title.clone()),
242 project_slug: project_slug_str,
243 is_free,
244 in_library,
245 has_access,
246 host_url: config.host_url.clone(),
247 discussion_url,
248 discussion_count,
249 }
250 .into_response());
251 }
252
253 let project_slug_str = db_project.slug.to_string();
254 let (discussion_url, discussion_count) = fetch_discussion_info(
255 integrations,
256 config,
257 db_item.mt_thread_id,
258 &project_slug_str,
259 "items",
260 )
261 .await;
262
263 // Convert bundle child items to view models
264 let bundle_item_views: Vec<Item> = bundle_child_items
265 .iter()
266 .map(|child| {
267 let child_tags = Vec::new(); // Tags not needed for bundle child list display
268 Item::from_db_list(child, &child_tags, child.price_cents == 0, false)
269 })
270 .collect();
271
272 // Convert containing bundles to view models
273 let containing_bundle_views: Vec<Item> = containing_bundles
274 .iter()
275 .map(|b| {
276 let b_tags = Vec::new();
277 Item::from_db_list(b, &b_tags, b.price_cents == 0, false)
278 })
279 .collect();
280
281 let db_sections = db::item_sections::list_by_item(db, db_item.id).await?;
282 let sections: Vec<ItemSection> = db_sections
283 .iter()
284 .map(|s| ItemSection::from_db(s, db_project.user_id, cdn_base))
285 .collect();
286
287 // Wishlist / cart / collection-count for the viewer, collapsed into one query
288 // (was three sequential round-trips). Display-only; a failure falls back to
289 // the empty default, matching the prior per-call error tolerance.
290 let viewer_flags = if let Some(ref user) = maybe_user {
291 db::items::get_viewer_item_flags(db, user.id, db_item.id)
292 .await
293 .unwrap_or_default()
294 } else {
295 db::items::ViewerItemFlags::default()
296 };
297 let is_wishlisted = viewer_flags.is_wishlisted;
298 let in_cart = viewer_flags.in_cart;
299 let collection_count = viewer_flags.collection_count as u32;
300
301 // Ordered gallery → carousel frames (additive to the cover image). Alt is
302 // creator-optional; fall back to a title-based description rather than an
303 // empty string (the carousel relies on alt for screen-reader parity, and
304 // CarouselFrame::new debug-asserts non-empty, so build the struct directly).
305 let gallery = db::gallery_images::list_for_item(db, db_item.id)
306 .await
307 .unwrap_or_default()
308 .into_iter()
309 .map(|g| crate::templates::CarouselFrame {
310 image: g.image_url,
311 alt: if g.alt.trim().is_empty() {
312 format!("{} gallery image", db_item.title)
313 } else {
314 g.alt
315 },
316 caption: None,
317 })
318 .collect();
319
320 Ok(ItemTemplate {
321 csrf_token,
322 session_user: maybe_user,
323 item,
324 creator_username: db_user.username.to_string(),
325 project_title: db_project.title.clone(),
326 project_slug: project_slug_str,
327 host_url: config.host_url.clone(),
328 project_cover_image_url: db_project.cover_image_url.clone(),
329 discussion_url,
330 discussion_count,
331 bundle_items: bundle_item_views,
332 containing_bundles: containing_bundle_views,
333 sections,
334 is_owner,
335 is_wishlisted,
336 in_cart,
337 collection_count,
338 has_access,
339 gallery,
340 theme_css: crate::theming::theme_css(db_project.theme_id.as_deref()),
341 }
342 .into_response())
343 }
344
345 // Segment builder for insertion playback
346
347 /// A player segment for the JS segment playlist.
348 #[derive(serde::Serialize)]
349 struct PlayerSegment {
350 url: String,
351 duration_ms: u32,
352 segment_type: String,
353 title: Option<String>,
354 }
355
356 /// Build the segments JSON for the media player. Returns "null" if no insertions.
357 pub(super) async fn build_segments_json(
358 db: &PgPool,
359 storage: &AppStorage,
360 item_id: ItemId,
361 media_url: Option<&String>,
362 db_item: &db::DbItem,
363 ) -> String {
364 let Ok(placements) =
365 db::content_insertions::list_playable_placements_for_item(db, item_id).await
366 else {
367 return "null".to_string();
368 };
369
370 if placements.is_empty() {
371 return "null".to_string();
372 }
373
374 let Some(s3) = &storage.s3 else {
375 return "null".to_string();
376 };
377
378 // Presign each insertion URL
379 let mut segments: Vec<PlayerSegment> = Vec::new();
380 let mut presigned_cache: std::collections::HashMap<String, String> =
381 std::collections::HashMap::new();
382
383 // Collect pre-rolls, mid-rolls (sorted by offset), and post-rolls
384 let mut pre_rolls = Vec::new();
385 let mut mid_rolls = Vec::new();
386 let mut post_rolls = Vec::new();
387
388 for p in &placements {
389 let url = if let Some(cached) = presigned_cache.get(&p.insertion_storage_key) {
390 cached.clone()
391 } else {
392 match s3
393 .presign_download(
394 &crate::storage::S3Key::from_stored(&p.insertion_storage_key),
395 Some(3600),
396 )
397 .await
398 {
399 Ok(url) => {
400 presigned_cache.insert(p.insertion_storage_key.clone(), url.clone());
401 url
402 }
403 Err(_) => continue,
404 }
405 };
406
407 let seg = PlayerSegment {
408 url,
409 duration_ms: p.insertion_duration_ms.max(0) as u32,
410 segment_type: p.position.to_string(),
411 title: Some(p.insertion_title.clone()),
412 };
413
414 match p.position {
415 db::InsertionPosition::PreRoll => pre_rolls.push(seg),
416 db::InsertionPosition::MidRoll => mid_rolls.push((p.offset_ms.unwrap_or(0), seg)),
417 db::InsertionPosition::PostRoll => post_rolls.push(seg),
418 }
419 }
420
421 // Get main content duration in ms
422 let main_duration_ms = match db_item.content() {
423 ContentData::Audio {
424 duration_seconds, ..
425 }
426 | ContentData::Video {
427 duration_seconds, ..
428 } => duration_seconds.map_or(0, |s| (s.max(0) as u64 * 1000).min(u32::MAX as u64) as u32),
429 _ => 0,
430 };
431
432 // Add pre-rolls
433 for seg in pre_rolls {
434 segments.push(seg);
435 }
436
437 // Sort mid-rolls by offset, then interleave with main content segments
438 mid_rolls.sort_by_key(|(offset, _)| *offset);
439
440 if mid_rolls.is_empty() {
441 // No mid-rolls: single main segment
442 if let Some(url) = media_url {
443 segments.push(PlayerSegment {
444 url: url.clone(),
445 duration_ms: main_duration_ms,
446 segment_type: "main".to_string(),
447 title: None,
448 });
449 }
450 } else {
451 // Split main content around mid-roll offsets
452 let mut last_offset_ms: u32 = 0;
453 if let Some(url) = media_url {
454 for (offset_ms, mid_seg) in mid_rolls {
455 let offset = offset_ms.max(0) as u32;
456 if offset > last_offset_ms {
457 // Main segment before this mid-roll
458 segments.push(PlayerSegment {
459 url: url.clone(),
460 duration_ms: offset - last_offset_ms,
461 segment_type: "main".to_string(),
462 title: None,
463 });
464 }
465 segments.push(mid_seg);
466 last_offset_ms = offset;
467 }
468 // Remaining main content after the last mid-roll
469 if last_offset_ms < main_duration_ms {
470 segments.push(PlayerSegment {
471 url: url.clone(),
472 duration_ms: main_duration_ms - last_offset_ms,
473 segment_type: "main".to_string(),
474 title: None,
475 });
476 }
477 }
478 }
479
480 // Add post-rolls
481 for seg in post_rolls {
482 segments.push(seg);
483 }
484
485 let Ok(json) = serde_json::to_string(&segments) else {
486 return "null".to_string();
487 };
488 escape_json_for_script_tag(&json)
489 }
490
491 /// Neutralize `</` so a JSON value can't break out of the surrounding
492 /// `<script>` block (serde_json does not escape `<`, `>`, or `/` by default, and
493 /// the result is emitted through Askama's `|safe` filter). Load-bearing: a
494 /// creator-controlled insertion title containing `</script>` would otherwise
495 /// inject markup. Pinned by `script_tag_breakout_is_neutralized`.
496 fn escape_json_for_script_tag(json: &str) -> String {
497 json.replace("</", "<\\/")
498 }
499
500 /// Build a short plain-text excerpt from raw markdown.
501 ///
502 /// Takes the first paragraph (up to the first blank line), strips obvious
503 /// markdown syntax, collapses whitespace, and truncates at `max_chars` with a
504 /// trailing ellipsis. Used for the store-page deck so visitors can preview a
505 /// paid article without unlocking the full body.
506 fn make_excerpt(body: &str, max_chars: usize) -> String {
507 let first_para = body
508 .split("\n\n")
509 .find(|p| !p.trim().is_empty())
510 .unwrap_or("");
511 let stripped: String = first_para
512 .lines()
513 .map(|line| line.trim_start_matches(['#', '>', '-', '*', ' ']))
514 .collect::<Vec<_>>()
515 .join(" ");
516 let plain: String = stripped
517 .replace(['*', '_', '`', '[', ']'], "")
518 .split_whitespace()
519 .collect::<Vec<_>>()
520 .join(" ");
521 if plain.chars().count() <= max_chars {
522 plain
523 } else {
524 let truncated: String = plain.chars().take(max_chars).collect();
525 format!("{}", truncated.trim_end())
526 }
527 }
528
529 #[cfg(test)]
530 mod tests {
531 use super::{escape_json_for_script_tag, make_excerpt};
532
533 #[test]
534 fn script_tag_breakout_is_neutralized() {
535 // A serialized value carrying `</script>` must not survive verbatim into
536 // the <script type="application/json"> block.
537 let raw = serde_json::to_string("</script><script>alert(1)</script>").unwrap();
538 let escaped = escape_json_for_script_tag(&raw);
539 assert!(
540 !escaped.contains("</script>"),
541 "literal </script> leaked: {escaped}"
542 );
543 assert!(
544 !escaped.contains("</"),
545 "no unescaped </ should remain: {escaped}"
546 );
547 assert!(
548 escaped.contains("<\\/script>"),
549 "expected escaped form: {escaped}"
550 );
551 }
552
553 #[test]
554 fn excerpt_short_passes_through() {
555 assert_eq!(make_excerpt("Hello world", 100), "Hello world");
556 }
557
558 #[test]
559 fn excerpt_first_paragraph_only() {
560 let body = "First paragraph.\n\nSecond paragraph should be ignored.";
561 assert_eq!(make_excerpt(body, 100), "First paragraph.");
562 }
563
564 #[test]
565 fn excerpt_strips_markdown_markers() {
566 let body = "# Heading\n**bold** and *italic* and `code` and [link](url)";
567 let out = make_excerpt(body, 100);
568 assert!(out.contains("Heading"));
569 assert!(out.contains("bold"));
570 assert!(!out.contains("**"));
571 assert!(!out.contains('`'));
572 }
573
574 #[test]
575 fn excerpt_truncates_with_ellipsis() {
576 let body = "a".repeat(500);
577 let out = make_excerpt(&body, 50);
578 assert_eq!(out.chars().count(), 51); // 50 chars + ellipsis
579 assert!(out.ends_with(''));
580 }
581
582 #[test]
583 fn excerpt_empty_body() {
584 assert_eq!(make_excerpt("", 100), "");
585 }
586 }
587