//! `/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 tower_sessions::Session; use crate::{ auth::MaybeUserVerified, constants, db::{self, ContentData, ItemId, ItemType}, error::{AppError, Result}, helpers::{fetch_discussion_info, get_csrf_token, get_initials}, pricing, templates::*, types::*, AppState, }; /// `GET /l/{item_id}`: render the library (consumption) view. #[tracing::instrument(skip_all, name = "content::library_page")] pub(in crate::routes::pages::public) async fn library_page( State(state): 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(&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); } let is_owner = maybe_user .as_ref() .map(|u| u.id == db_project.user_id) .unwrap_or(false); // 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(&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); 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; } let item_tags = db::tags::get_tags_for_item(&state.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 { let bundle_ids = db::bundles::get_bundles_containing_item(&state.db, db_item.id).await?; let mut bundles = Vec::new(); for bid in &bundle_ids { if let Some(b) = db::items::get_item_by_id(&state.db, *bid).await? && b.is_public { let tags = Vec::new(); bundles.push(Item::from_db_list(&b, &tags, b.price_cents == 0, false)); } } bundles } else { Vec::new() }; 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: state.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(&state, "item", *db_item.id); } let db_versions = db::versions::get_versions_by_item(&state.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(&state, 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( &state, &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( &state, &db_item, &db_user, &db_project, csrf_token, maybe_user, item, discussion_url, discussion_count, is_owner, ) .await; } // Phase 3: video items get their own player template. if db_item.item_type == ItemType::Video { return render_video_library( &state, &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(&state.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 = state .config .cdn_base_url .as_deref() .unwrap_or("https://cdn.makenot.work"); 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(); 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: state.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( state: &AppState, 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(&state.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, &state.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(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(state, db_item.id, &audio_url, 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: state.config.host_url.clone(), discussion_url, discussion_count, is_owner, } .into_response()) } #[allow(clippy::too_many_arguments)] async fn render_video_library( state: &AppState, 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(&state.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, &state.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(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(state, db_item.id, &video_url, 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: state.config.host_url.clone(), discussion_url, discussion_count, is_owner, } .into_response()) } #[allow(clippy::too_many_arguments)] async fn render_text_library( state: &AppState, 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 = state .config .cdn_base_url .as_deref() .unwrap_or("https://cdn.makenot.work"); 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!("{} min read", m)), ), _ => (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: state.config.host_url.clone(), discussion_url, discussion_count, is_owner, } .into_response()) }