//! Public item detail page handler. use axum::{ extract::{Path, State}, response::{IntoResponse, Response}, }; use tower_sessions::Session; use crate::{ auth::{MaybeUserVerified, SessionUser}, db::{self, ContentData, ItemId, ItemType}, error::{AppError, Result}, helpers::{fetch_discussion_info, get_csrf_token, get_initials}, pricing, templates::*, types::*, AppState, }; /// 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(state): 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(&state.db, id) .await? .ok_or(AppError::NotFound)?; let db_project = db::projects::get_project_by_id(&state.db, db_item.project_id) .await? .ok_or(AppError::NotFound)?; let db_user = db::users::get_user_by_id(&state.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(&state, &db_item, &db_project, &db_user, csrf_token, maybe_user).await } /// Shared item page renderer, used by both named routes and custom domain fallback. pub(crate) async fn render_item_page( state: &AppState, 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() .map(|u| u.id == db_project.user_id) .unwrap_or(false); 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 = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work"); // 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!("{} min read", m)), ), _ => (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(&state.db, user.id, db_item.id).await? } else { false }; let item_sub = if let Some(ref user) = maybe_user { db::subscriptions::SubscriptionGate::check(&state.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(&state.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 { db::bundles::get_bundles_containing_item(&state.db, db_item.id).await? } else { vec![] }; let containing_bundles: Vec = { let mut bundles = Vec::new(); for bid in &containing_bundle_ids { if let Some(b) = db::items::get_item_by_id(&state.db, *bid).await? && b.is_public { bundles.push(b); } } bundles }; // For bundle-type items, load the child items let bundle_child_items = if db_item.item_type == ItemType::Bundle { db::bundles::get_bundle_items(&state.db, db_item.id).await? } else { vec![] }; let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?; let item = Item::from_db_detail( db_item, &item_tags, None, reading_time.clone(), is_free, has_access, ); 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(state, 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: state.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(state, 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: state.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(state, 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: state.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(state, 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 Item::from_db_list(child, &child_tags, child.price_cents == 0, false) }) .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) }) .collect(); let db_sections = db::item_sections::list_by_item(&state.db, db_item.id).await?; let sections: Vec = db_sections.iter().map(|s| ItemSection::from_db(s, db_project.user_id, cdn_base)).collect(); let is_wishlisted = if let Some(ref user) = maybe_user { db::wishlists::is_wishlisted(&state.db, user.id, db_item.id).await.unwrap_or(false) } else { false }; let in_cart = if let Some(ref user) = maybe_user { db::cart::is_in_cart(&state.db, user.id, db_item.id).await.unwrap_or(false) } else { false }; let collection_count = if let Some(ref user) = maybe_user { db::collections::count_user_collections_containing_item(&state.db, user.id, db_item.id) .await .unwrap_or(0) as u32 } else { 0 }; // 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(&state.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, creator_username: db_user.username.to_string(), project_title: db_project.title.clone(), project_slug: project_slug_str, host_url: state.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, } .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( state: &AppState, item_id: ItemId, media_url: &Option, db_item: &db::DbItem, ) -> String { let placements = match db::content_insertions::list_placements_for_item(&state.db, item_id).await { Ok(p) => p, Err(_) => return "null".to_string(), }; if placements.is_empty() { return "null".to_string(); } let s3 = match &state.s3 { Some(s3) => s3, None => 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(&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(|s| (s.max(0) as u64 * 1000).min(u32::MAX as u64) as u32).unwrap_or(0) } _ => 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 json = match serde_json::to_string(&segments) { Ok(j) => j, Err(_) => 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: String) -> 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), ""); } }