//! Public item detail page handler. use axum::{ extract::{Path, State}, response::{IntoResponse, Response}, }; use sqlx::PgPool; use tower_sessions::Session; use crate::{ AppStorage, Integrations, auth::{MaybeUserVerified, SessionUser}, config::Config, db::{self, ContentData, ItemId, ItemType}, error::{AppError, Result}, helpers::{fetch_discussion_info, get_csrf_token, get_initials}, pricing, templates::{AudioPlayerTemplate, ItemTemplate, TextReaderTemplate, VideoPlayerTemplate}, types::{Item, ItemSection}, }; /// Render a public item detail page (text reader, audio player, or download). #[tracing::instrument(skip_all, name = "content::item_page")] pub(in crate::routes::pages::public) async fn item_page( State(db): State, State(integrations): State, State(config): State, session: Session, MaybeUserVerified(maybe_user): MaybeUserVerified, Path(item_id): Path, ) -> Result { let csrf_token = get_csrf_token(&session).await; let id: ItemId = item_id.parse().map_err(|_| AppError::NotFound)?; let db_item = db::items::get_item_by_id(&db, id) .await? .ok_or(AppError::NotFound)?; let db_project = db::projects::get_project_by_id(&db, db_item.project_id) .await? .ok_or(AppError::NotFound)?; let db_user = db::users::get_user_by_id(&db, db_project.user_id) .await? .ok_or(AppError::NotFound)?; if db_user.is_sandbox { return Err(AppError::NotFound); } // View tracking moved to /l/{id}, consumption is the meaningful signal, // not store-page traffic. render_item_page( &db, &integrations, &config, &db_item, &db_project, &db_user, csrf_token, maybe_user, ) .await } /// Shared item page renderer, used by both named routes and custom domain fallback. #[allow(clippy::too_many_arguments)] pub(crate) async fn render_item_page( db: &PgPool, integrations: &Integrations, config: &Config, db_item: &db::DbItem, db_project: &db::DbProject, db_user: &db::DbUser, csrf_token: Option, maybe_user: Option, ) -> Result { // Visibility check: unpublished items only visible to owner let is_owner = maybe_user .as_ref() .is_some_and(|u| u.id == db_project.user_id); if !db_item.is_public && !is_owner { return Err(AppError::NotFound); } if db_item.deleted_at.is_some() && !is_owner { return Err(AppError::NotFound); } let cdn_base = config.cdn_base_url.as_str(); // Store page never renders the full article body, that lives on /l/{id}. // Compute a short plain-text excerpt from the raw markdown for the deck. let (excerpt, reading_time) = match db_item.content() { ContentData::Text { body, reading_time_minutes, .. } => ( body.as_ref().map(|b| make_excerpt(b, 280)), reading_time_minutes.map(|m| format!("{m} min read")), ), _ => (None, None), }; let item_pricing = pricing::for_item(db_item); let in_library = if let Some(ref user) = maybe_user { db::transactions::has_purchased_item(db, user.id, db_item.id).await? } else { false }; let item_sub = if let Some(ref user) = maybe_user { db::subscriptions::SubscriptionGate::check( db, user.id, db::subscriptions::SubscriptionScope::Item(db_item.id), ) .await? } else { None }; let ctx = pricing::AccessContext { is_creator: is_owner, has_purchased: in_library, subscription: item_sub, }; let mut has_access = item_pricing.can_access(&ctx); let is_free = item_pricing.is_free(); // Bundle access: user may have purchased a bundle containing this item if !has_access && let Some(ref user) = maybe_user && db::bundles::has_access_via_bundle(db, user.id, db_item.id).await? { has_access = true; } // For unlisted items, load the bundles that contain them (for "Available in" display) let containing_bundle_ids = if db_item.listed { vec![] } else { db::bundles::get_bundles_containing_item(db, db_item.id).await? }; // Single batched query instead of one get_item_by_id per bundle id (N+1). let containing_bundles: Vec = db::items::get_public_items_by_ids(db, &containing_bundle_ids).await?; // For bundle-type items, load the child items let bundle_child_items = if db_item.item_type == ItemType::Bundle { db::bundles::get_bundle_items(db, db_item.id).await? } else { vec![] }; let item_tags = db::tags::get_tags_for_item(db, db_item.id).await?; let item = Item::from_db_detail( db_item, &item_tags, None, reading_time.clone(), is_free, has_access, db_user.settlement_currency, ); if db_item.item_type == ItemType::Text { let avatar_initials = get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username)); let project_slug_str = db_project.slug.to_string(); let (discussion_url, discussion_count) = fetch_discussion_info( integrations, config, db_item.mt_thread_id, &project_slug_str, "items", ) .await; return Ok(TextReaderTemplate { csrf_token: csrf_token.clone(), session_user: maybe_user, item, creator_username: db_user.username.to_string(), creator_display_name: db_user.display_name.clone(), creator_avatar_initials: avatar_initials, project_title: db_project.title.clone(), project_slug: project_slug_str, is_free, in_library, has_access, reading_time, excerpt, host_url: config.host_url.clone(), discussion_url, discussion_count, } .into_response()); } if db_item.item_type == ItemType::Audio { let avatar_initials = get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username)); let project_slug_str = db_project.slug.to_string(); let (discussion_url, discussion_count) = fetch_discussion_info( integrations, config, db_item.mt_thread_id, &project_slug_str, "items", ) .await; return Ok(AudioPlayerTemplate { csrf_token: csrf_token.clone(), session_user: maybe_user, item, creator_username: db_user.username.to_string(), creator_display_name: db_user.display_name.clone(), creator_avatar_initials: avatar_initials, project_title: Some(db_project.title.clone()), project_slug: project_slug_str, is_free, in_library, has_access, host_url: config.host_url.clone(), discussion_url, discussion_count, } .into_response()); } if db_item.item_type == ItemType::Video { let avatar_initials = get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username)); let project_slug_str = db_project.slug.to_string(); let (discussion_url, discussion_count) = fetch_discussion_info( integrations, config, db_item.mt_thread_id, &project_slug_str, "items", ) .await; return Ok(VideoPlayerTemplate { csrf_token: csrf_token.clone(), session_user: maybe_user, item, creator_username: db_user.username.to_string(), creator_display_name: db_user.display_name.clone(), creator_avatar_initials: avatar_initials, project_title: Some(db_project.title.clone()), project_slug: project_slug_str, is_free, in_library, has_access, host_url: config.host_url.clone(), discussion_url, discussion_count, } .into_response()); } let project_slug_str = db_project.slug.to_string(); let (discussion_url, discussion_count) = fetch_discussion_info( integrations, config, db_item.mt_thread_id, &project_slug_str, "items", ) .await; // Convert bundle child items to view models let bundle_item_views: Vec = bundle_child_items .iter() .map(|child| { let child_tags = Vec::new(); // Tags not needed for bundle child list display // A bundle's children live in the same project, so one creator and one // currency: the page's. Item::from_db_list( child, &child_tags, child.price_cents == 0, false, db_user.settlement_currency, ) }) .collect(); // Convert containing bundles to view models let containing_bundle_views: Vec = containing_bundles .iter() .map(|b| { let b_tags = Vec::new(); Item::from_db_list( b, &b_tags, b.price_cents == 0, false, db_user.settlement_currency, ) }) .collect(); let db_sections = db::item_sections::list_by_item(db, db_item.id).await?; let sections: Vec = db_sections .iter() .map(|s| ItemSection::from_db(s, db_project.user_id, cdn_base)) .collect(); // Wishlist / cart / collection-count for the viewer, collapsed into one query // (was three sequential round-trips). Display-only; a failure falls back to // the empty default, matching the prior per-call error tolerance. let viewer_flags = if let Some(ref user) = maybe_user { db::items::get_viewer_item_flags(db, user.id, db_item.id) .await .unwrap_or_default() } else { db::items::ViewerItemFlags::default() }; let is_wishlisted = viewer_flags.is_wishlisted; let in_cart = viewer_flags.in_cart; let collection_count = viewer_flags.collection_count as u32; // Ordered gallery → carousel frames (additive to the cover image). Alt is // creator-optional; fall back to a title-based description rather than an // empty string (the carousel relies on alt for screen-reader parity, and // CarouselFrame::new debug-asserts non-empty, so build the struct directly). let gallery = db::gallery_images::list_for_item(db, db_item.id) .await .unwrap_or_default() .into_iter() .map(|g| crate::templates::CarouselFrame { image: g.image_url, alt: if g.alt.trim().is_empty() { format!("{} gallery image", db_item.title) } else { g.alt }, caption: None, }) .collect(); Ok(ItemTemplate { csrf_token, session_user: maybe_user, item, price_currency: db_user.settlement_currency.code_upper(), creator_username: db_user.username.to_string(), project_title: db_project.title.clone(), project_slug: project_slug_str, host_url: config.host_url.clone(), project_cover_image_url: db_project.cover_image_url.clone(), discussion_url, discussion_count, bundle_items: bundle_item_views, containing_bundles: containing_bundle_views, sections, is_owner, is_wishlisted, in_cart, collection_count, has_access, gallery, theme_css: crate::theming::theme_css(db_project.theme_id.as_deref()), } .into_response()) } // Segment builder for insertion playback /// A player segment for the JS segment playlist. #[derive(serde::Serialize)] struct PlayerSegment { url: String, duration_ms: u32, segment_type: String, title: Option, } /// Build the segments JSON for the media player. Returns "null" if no insertions. pub(super) async fn build_segments_json( db: &PgPool, storage: &AppStorage, item_id: ItemId, media_url: Option<&String>, db_item: &db::DbItem, ) -> String { let Ok(placements) = db::content_insertions::list_playable_placements_for_item(db, item_id).await else { return "null".to_string(); }; if placements.is_empty() { return "null".to_string(); } let Some(s3) = &storage.s3 else { return "null".to_string(); }; // Presign each insertion URL let mut segments: Vec = Vec::new(); let mut presigned_cache: std::collections::HashMap = std::collections::HashMap::new(); // Collect pre-rolls, mid-rolls (sorted by offset), and post-rolls let mut pre_rolls = Vec::new(); let mut mid_rolls = Vec::new(); let mut post_rolls = Vec::new(); for p in &placements { let url = if let Some(cached) = presigned_cache.get(&p.insertion_storage_key) { cached.clone() } else { match s3 .presign_download( &crate::storage::S3Key::from_stored(&p.insertion_storage_key), Some(3600), ) .await { Ok(url) => { presigned_cache.insert(p.insertion_storage_key.clone(), url.clone()); url } Err(_) => continue, } }; let seg = PlayerSegment { url, duration_ms: p.insertion_duration_ms.max(0) as u32, segment_type: p.position.to_string(), title: Some(p.insertion_title.clone()), }; match p.position { db::InsertionPosition::PreRoll => pre_rolls.push(seg), db::InsertionPosition::MidRoll => mid_rolls.push((p.offset_ms.unwrap_or(0), seg)), db::InsertionPosition::PostRoll => post_rolls.push(seg), } } // Get main content duration in ms let main_duration_ms = match db_item.content() { ContentData::Audio { duration_seconds, .. } | ContentData::Video { duration_seconds, .. } => duration_seconds.map_or(0, |s| (s.max(0) as u64 * 1000).min(u32::MAX as u64) as u32), _ => 0, }; // Add pre-rolls for seg in pre_rolls { segments.push(seg); } // Sort mid-rolls by offset, then interleave with main content segments mid_rolls.sort_by_key(|(offset, _)| *offset); if mid_rolls.is_empty() { // No mid-rolls: single main segment if let Some(url) = media_url { segments.push(PlayerSegment { url: url.clone(), duration_ms: main_duration_ms, segment_type: "main".to_string(), title: None, }); } } else { // Split main content around mid-roll offsets let mut last_offset_ms: u32 = 0; if let Some(url) = media_url { for (offset_ms, mid_seg) in mid_rolls { let offset = offset_ms.max(0) as u32; if offset > last_offset_ms { // Main segment before this mid-roll segments.push(PlayerSegment { url: url.clone(), duration_ms: offset - last_offset_ms, segment_type: "main".to_string(), title: None, }); } segments.push(mid_seg); last_offset_ms = offset; } // Remaining main content after the last mid-roll if last_offset_ms < main_duration_ms { segments.push(PlayerSegment { url: url.clone(), duration_ms: main_duration_ms - last_offset_ms, segment_type: "main".to_string(), title: None, }); } } } // Add post-rolls for seg in post_rolls { segments.push(seg); } let Ok(json) = serde_json::to_string(&segments) else { return "null".to_string(); }; escape_json_for_script_tag(&json) } /// Neutralize `` block (serde_json does not escape `<`, `>`, or `/` by default, and /// the result is emitted through Askama's `|safe` filter). Load-bearing: a /// creator-controlled insertion title containing `` would otherwise /// inject markup. Pinned by `script_tag_breakout_is_neutralized`. fn escape_json_for_script_tag(json: &str) -> String { json.replace(" String { let first_para = body .split("\n\n") .find(|p| !p.trim().is_empty()) .unwrap_or(""); let stripped: String = first_para .lines() .map(|line| line.trim_start_matches(['#', '>', '-', '*', ' '])) .collect::>() .join(" "); let plain: String = stripped .replace(['*', '_', '`', '[', ']'], "") .split_whitespace() .collect::>() .join(" "); if plain.chars().count() <= max_chars { plain } else { let truncated: String = plain.chars().take(max_chars).collect(); format!("{}…", truncated.trim_end()) } } #[cfg(test)] mod tests { use super::{escape_json_for_script_tag, make_excerpt}; #[test] fn script_tag_breakout_is_neutralized() { // A serialized value carrying `` must not survive verbatim into // the ").unwrap(); let escaped = escape_json_for_script_tag(&raw); assert!( !escaped.contains(""), "literal leaked: {escaped}" ); assert!( !escaped.contains(""), "expected escaped form: {escaped}" ); } #[test] fn excerpt_short_passes_through() { assert_eq!(make_excerpt("Hello world", 100), "Hello world"); } #[test] fn excerpt_first_paragraph_only() { let body = "First paragraph.\n\nSecond paragraph should be ignored."; assert_eq!(make_excerpt(body, 100), "First paragraph."); } #[test] fn excerpt_strips_markdown_markers() { let body = "# Heading\n**bold** and *italic* and `code` and [link](url)"; let out = make_excerpt(body, 100); assert!(out.contains("Heading")); assert!(out.contains("bold")); assert!(!out.contains("**")); assert!(!out.contains('`')); } #[test] fn excerpt_truncates_with_ellipsis() { let body = "a".repeat(500); let out = make_excerpt(&body, 50); assert_eq!(out.chars().count(), 51); // 50 chars + ellipsis assert!(out.ends_with('…')); } #[test] fn excerpt_empty_body() { assert_eq!(make_excerpt("", 100), ""); } }