//! `/l/{item_id}`: library (consumption) view for items the viewer has access to. //! //! Separate from `/i/{id}` (store page). 403 if the viewer doesn't have access; //! 404 if the item is missing, unpublished/deleted, or owned by a sandbox seller. use axum::{ extract::{Path, State}, response::{IntoResponse, Response}, }; use sqlx::PgPool; use tower_sessions::Session; use crate::{ AppStorage, Integrations, auth::MaybeUserVerified, config::Config, constants, db::{self, ContentData, ItemId, ItemType}, error::{AppError, Result}, helpers::{fetch_discussion_info, get_csrf_token, get_initials}, pricing, templates::{ LibraryAudioTemplate, LibraryDownloadsTemplate, LibraryLockedTemplate, LibraryTextTemplate, LibraryVideoTemplate, }, types::{Chapter, Item, ItemSection, Version}, }; /// `GET /l/{item_id}`: render the library (consumption) view. #[allow(clippy::too_many_arguments)] #[tracing::instrument(skip_all, name = "content::library_page")] pub(in crate::routes::pages::public) async fn library_page( State(db): State, State(storage): State, State(config): State, State(integrations): State, State(page_view_tx): State, session: Session, headers: axum::http::HeaderMap, 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); } let is_owner = maybe_user .as_ref() .is_some_and(|u| u.id == db_project.user_id); // Unpublished or soft-deleted items: hide from non-owners (don't leak draft existence). if (!db_item.is_public || db_item.deleted_at.is_some()) && !is_owner { return Err(AppError::NotFound); } // Compute access using the same logic as item_page. 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); 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; } let item_tags = db::tags::get_tags_for_item(&db, db_item.id).await?; let is_free = item_pricing.is_free(); let item = Item::from_db_detail(&db_item, &item_tags, None, None, is_free, has_access); if !has_access { // Render 403 with link back to /i/{id}. For unlisted items, list containing bundles. let containing_bundles: Vec = if db_item.listed { Vec::new() } else { let bundle_ids = db::bundles::get_bundles_containing_item(&db, db_item.id).await?; // Batch-fetch the public bundles in one query (Perf-MIN N+1); // get_public_items_by_ids already filters to is_public, matching the // per-item check the old loop did. db::items::get_public_items_by_ids(&db, &bundle_ids) .await? .iter() .map(|b| { let tags = Vec::new(); Item::from_db_list(b, &tags, b.price_cents == 0, false) }) .collect() }; let is_logged_in = maybe_user.is_some(); return Ok(( axum::http::StatusCode::FORBIDDEN, LibraryLockedTemplate { csrf_token, session_user: maybe_user, item, creator_username: db_user.username.to_string(), host_url: config.host_url.clone(), containing_bundles, is_logged_in, }, ) .into_response()); } // View tracking belongs on /l/ (consumption signal), not /i/. let ua = headers .get(axum::http::header::USER_AGENT) .and_then(|v| v.to_str().ok()) .unwrap_or(""); if !super::is_bot(ua) { super::track_view(&page_view_tx, "item", *db_item.id); } let db_versions = db::versions::get_versions_by_item(&db, db_item.id).await?; let versions: Vec = db_versions.iter().map(Version::from_db).collect(); 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; // Phase 2: audio items get their own player template. if db_item.item_type == ItemType::Audio { return render_audio_library( &db, &storage, &config, &db_item, &db_user, &db_project, csrf_token, maybe_user, item, versions, discussion_url, discussion_count, is_owner, ) .await; } // Phase 4: text items get their own reader template. if db_item.item_type == ItemType::Text { return render_text_library( &config, &db_item, &db_user, &db_project, csrf_token, maybe_user, item, discussion_url, discussion_count, is_owner, ); } // Phase 3: video items get their own player template. if db_item.item_type == ItemType::Video { return render_video_library( &db, &storage, &config, &db_item, &db_user, &db_project, csrf_token, maybe_user, item, versions, discussion_url, discussion_count, is_owner, ) .await; } // Phase 1: downloads / bundle / other items render here. Audio, video, and // text branches above handle their own templates. let bundle_child_items = if db_item.item_type == ItemType::Bundle { db::bundles::get_bundle_items(&db, db_item.id).await? } else { Vec::new() }; let bundle_items: Vec = bundle_child_items .iter() .map(|child| { let child_tags = Vec::new(); Item::from_db_list(child, &child_tags, child.price_cents == 0, false) }) .collect(); let cdn_base = config.cdn_base_url.as_str(); 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(); Ok(LibraryDownloadsTemplate { 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: config.host_url.clone(), versions, bundle_items, sections, discussion_url, discussion_count, is_owner, } .into_response()) } #[allow(clippy::too_many_arguments)] async fn render_audio_library( db: &PgPool, storage: &AppStorage, config: &Config, db_item: &db::DbItem, db_user: &db::DbUser, db_project: &db::DbProject, csrf_token: Option, maybe_user: Option, item: Item, versions: Vec, discussion_url: Option, discussion_count: Option, is_owner: bool, ) -> Result { let avatar_initials = get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username)); let db_chapters = db::chapters::get_chapters_by_item(db, db_item.id).await?; let chapters: Vec = db_chapters.iter().map(Chapter::from).collect(); let audio_url = match db_item.content() { ContentData::Audio { audio_s3_key, duration_seconds, audio_url, .. } => { if let (Some(s3_key), Some(s3)) = (&audio_s3_key, &storage.s3) { let expiry_secs = match duration_seconds { Some(duration) => { ((duration as u64) * 2).clamp(3600, constants::STREAMING_CACHE_MAX_SECS) } None => 3600, }; match s3 .presign_download( &crate::storage::S3Key::from_stored(s3_key), Some(expiry_secs), ) .await { Ok(url) => Some(url), Err(e) => { tracing::warn!(s3_key = %s3_key, error = ?e, "failed to generate presigned url"); audio_url } } } else { audio_url } } _ => None, }; let segments_json = super::item::build_segments_json(db, storage, db_item.id, audio_url.as_ref(), db_item) .await; Ok(LibraryAudioTemplate { csrf_token, 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: db_project.slug.to_string(), audio_url, chapters, segments_json, versions, host_url: config.host_url.clone(), discussion_url, discussion_count, is_owner, } .into_response()) } #[allow(clippy::too_many_arguments)] async fn render_video_library( db: &PgPool, storage: &AppStorage, config: &Config, db_item: &db::DbItem, db_user: &db::DbUser, db_project: &db::DbProject, csrf_token: Option, maybe_user: Option, item: Item, versions: Vec, discussion_url: Option, discussion_count: Option, is_owner: bool, ) -> Result { let avatar_initials = get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username)); let db_chapters = db::chapters::get_chapters_by_item(db, db_item.id).await?; let chapters: Vec = db_chapters.iter().map(Chapter::from).collect(); let video_url = match db_item.content() { ContentData::Video { video_s3_key, duration_seconds, .. } => { if let (Some(s3_key), Some(s3)) = (&video_s3_key, &storage.s3) { let expiry_secs = match duration_seconds { Some(duration) => { ((duration as u64) * 2).clamp(3600, constants::STREAMING_CACHE_MAX_SECS) } None => 3600, }; match s3 .presign_download( &crate::storage::S3Key::from_stored(s3_key), Some(expiry_secs), ) .await { Ok(url) => Some(url), Err(e) => { tracing::warn!(s3_key = %s3_key, error = ?e, "failed to generate presigned video url"); None } } } else { None } } _ => None, }; let segments_json = super::item::build_segments_json(db, storage, db_item.id, video_url.as_ref(), db_item) .await; Ok(LibraryVideoTemplate { csrf_token, 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: db_project.slug.to_string(), video_url, chapters, segments_json, versions, host_url: config.host_url.clone(), discussion_url, discussion_count, is_owner, } .into_response()) } #[allow(clippy::too_many_arguments)] fn render_text_library( config: &Config, db_item: &db::DbItem, db_user: &db::DbUser, db_project: &db::DbProject, csrf_token: Option, maybe_user: Option, item: Item, discussion_url: Option, discussion_count: Option, is_owner: bool, ) -> Result { let avatar_initials = get_initials(db_user.display_name.as_deref().unwrap_or(&db_user.username)); let cdn_base = config.cdn_base_url.as_str(); let (body_html, reading_time) = match db_item.content() { ContentData::Text { body, reading_time_minutes, .. } => ( body.as_ref() .map(|b| crate::markdown::render_creator_markdown(b, db_project.user_id, cdn_base)), reading_time_minutes.map(|m| format!("{m} min read")), ), _ => (None, None), }; Ok(LibraryTextTemplate { csrf_token, 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: db_project.slug.to_string(), body_html, reading_time, host_url: config.host_url.clone(), discussion_url, discussion_count, is_owner, } .into_response()) }