//! Public project page handler. use axum::{ extract::{Path, State}, response::{IntoResponse, Response}, }; use tower_sessions::Session; use crate::{ auth::{MaybeUserVerified, SessionUser}, db::{self, FollowTargetType, ItemId, ItemType, Slug}, error::{AppError, Result}, helpers::get_csrf_token, pricing, templates::*, types::*, AppState, }; /// Render a public project page with its published items. #[tracing::instrument(skip_all, name = "content::project_page", fields(%slug))] pub(in crate::routes::pages::public) async fn project_page( State(state): State, session: Session, headers: axum::http::HeaderMap, MaybeUserVerified(maybe_user): MaybeUserVerified, Path(slug): Path, ) -> Result { let csrf_token = get_csrf_token(&session).await; let slug = Slug::new(&slug).map_err(|_| AppError::NotFound)?; let db_project = db::projects::get_public_project_by_slug(&state.db, &slug) .await? .ok_or(AppError::NotFound)?; let response = render_project_page(&state, &db_project, csrf_token, maybe_user).await?; 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, "project", *db_project.id); } Ok(response) } /// Shared project page renderer, used by both named routes and custom domain fallback. #[tracing::instrument( skip_all, name = "content::render_project_page", fields(project_id = %db_project.id, project_slug = %db_project.slug, viewer_id = ?maybe_user.as_ref().map(|u| u.id)) )] pub(crate) async fn render_project_page( state: &AppState, db_project: &db::DbProject, csrf_token: Option, maybe_user: Option, ) -> Result { let db_user = db::users::get_user_by_id(&state.db, db_project.user_id) .await? .ok_or(AppError::NotFound)?; // Project-level paywall gate let project_pricing = pricing::for_project(db_project); if !project_pricing.is_free() { let project_ctx = pricing::build_project_access_context( &state.db, maybe_user.as_ref().map(|u| u.id), db_project.id, db_project.user_id, ) .await?; if !project_pricing.can_access(&project_ctx) { tracing::warn!( project_id = %db_project.id, project_slug = %db_project.slug, creator_user_id = %db_project.user_id, viewer_user_id = ?maybe_user.as_ref().map(|u| u.id), is_creator = project_ctx.is_creator, has_purchased = project_ctx.has_purchased, has_active_subscription = project_ctx.has_active_subscription(), pricing_kind = ?project_pricing.kind(), "project paywall gate: showing paywall" ); let db_tiers = db::subscriptions::get_active_tiers_by_project(&state.db, db_project.id).await?; let subscription_tiers: Vec = db_tiers.iter().map(SubscriptionTier::from).collect(); let project = Project::from_db(db_project, 0); return Ok(ProjectPaywallTemplate { csrf_token, session_user: maybe_user, project, creator_username: db_user.username.to_string(), price_display: project_pricing.price_display(), checkout_type: project_pricing.checkout_type(), subscription_tiers, host_url: state.config.host_url.clone(), } .into_response()); } } let db_items = db::items::get_public_items_by_project(&state.db, db_project.id).await?; let is_creator = maybe_user .as_ref() .map(|u| u.id == db_project.user_id) .unwrap_or(false); let purchased_item_ids: std::collections::HashSet = if let Some(ref user) = maybe_user { db::transactions::get_user_purchased_item_ids(&state.db, user.id) .await? .into_iter() .collect() } else { std::collections::HashSet::new() }; // Per-item subscription proofs for this user, so each item's AccessContext // gets its own gate witness rather than a bare membership bool. let subscribed_gates = if let Some(ref user) = maybe_user { db::subscriptions::SubscriptionGate::subscribed_item_gates(&state.db, user.id).await? } else { std::collections::HashMap::new() }; let has_subscription = if let Some(ref user) = maybe_user { db::subscriptions::has_access(&state.db, user.id, db::subscriptions::SubscriptionScope::Project(db_project.id)) .await? } else { false }; let project = Project::from_db(db_project, db_items.len() as u32); let item_ids: Vec = db_items.iter().map(|i| i.id).collect(); let tags_map = db::tags::get_tags_for_items(&state.db, &item_ids).await?; // Batch child-counts for all bundles on the page in one query (was one // COUNT per bundle inside the loop below). let bundle_ids: Vec = db_items .iter() .filter(|i| i.item_type == ItemType::Bundle) .map(|i| i.id) .collect(); let bundle_counts = if bundle_ids.is_empty() { std::collections::HashMap::new() } else { db::bundles::get_bundle_item_counts(&state.db, &bundle_ids).await? }; let mut items: Vec = Vec::with_capacity(db_items.len()); for i in &db_items { let item_pricing = pricing::for_item(i); let ctx = pricing::AccessContext { is_creator, has_purchased: purchased_item_ids.contains(&i.id), subscription: subscribed_gates.get(&i.id).copied(), }; let can_access = item_pricing.can_access(&ctx); let is_free = item_pricing.is_free(); let item_tags = tags_map.get(&i.id).map(|v| v.as_slice()).unwrap_or(&[]); let mut item = Item::from_db_list(i, item_tags, is_free, can_access); if i.item_type == ItemType::Bundle { item.bundle_item_count = bundle_counts.get(&i.id).copied().unwrap_or(0); } items.push(item); } let follower_count = db::follows::get_follower_count( &state.db, FollowTargetType::Project, db_project.id.into(), ) .await?; let is_following = if let Some(ref viewer) = maybe_user { db::follows::is_following( &state.db, viewer.id, FollowTargetType::Project, db_project.id.into(), ) .await? } else { false }; let db_tiers = db::subscriptions::get_active_tiers_by_project(&state.db, db_project.id).await?; let subscription_tiers: Vec = db_tiers.iter().map(SubscriptionTier::from).collect(); let git_repos = if state.config.git_repos_path.is_some() { let linked = db::git_repos::get_repos_by_project(&state.db, db_project.id) .await .unwrap_or_else(|e| { tracing::warn!(project_id = %db_project.id, error = %e, "linked git repos lookup failed; omitting repo links"); Default::default() }); linked .into_iter() .map(|r| { let url = format!("/git/{}/{}", db_user.username, r.name); (r.name, url) }) .collect() } else { Vec::new() }; let has_blog_posts = db::blog_posts::has_published_posts(&state.db, db_project.id).await?; let community_url = if db_project.mt_community_id.is_some() { state .config .mt_base_url .as_ref() .map(|base| format!("{}/p/{}", base, db_project.slug)) } else { None }; let is_owner = maybe_user .as_ref() .is_some_and(|u| u.id == db_project.user_id); let db_sections = db::project_sections::list_by_project(&state.db, db_project.id).await?; let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work"); let sections: Vec = db_sections .iter() .map(|s| ProjectSection::from_db(s, db_project.user_id, cdn_base)) .collect(); // Ordered gallery → carousel frames (additive to the cover image). Alt is // creator-optional; fall back to a title-based description (CarouselFrame::new // debug-asserts non-empty alt, so build the struct directly). let gallery = db::gallery_images::list_for_project(&state.db, db_project.id) .await .unwrap_or_else(|e| { tracing::warn!(project_id = %db_project.id, error = %e, "gallery image lookup failed; rendering without carousel"); Default::default() }) .into_iter() .map(|g| crate::templates::CarouselFrame { image: g.image_url, alt: if g.alt.trim().is_empty() { format!("{} gallery image", db_project.title) } else { g.alt }, caption: None, }) .collect(); Ok(ProjectTemplate { csrf_token, session_user: maybe_user, project, creator_username: db_user.username.to_string(), items, project_id: db_project.id.to_string(), is_following, follower_count, subscription_tiers, has_subscription, host_url: state.config.host_url.clone(), git_repos, has_blog_posts, community_url, tips_enabled: db_user.tips_enabled && db_user.stripe_charges_enabled, creator_id: db_user.id.to_string(), tip_project_id: Some(db_project.id.to_string()), is_owner, sections, gallery, } .into_response()) }