//! Public project page handler. use axum::{ extract::{Path, State}, response::{IntoResponse, Response}, }; use sqlx::PgPool; use tower_sessions::Session; use crate::{ auth::{MaybeUserVerified, SessionUser}, config::Config, db::{self, FollowTargetType, ItemId, ItemType, Slug}, error::{AppError, Result}, helpers::get_csrf_token, pricing, templates::ProjectPaywallTemplate, types::{Item, Project, ProjectSection, SubscriptionTier}, }; /// 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(db): State, State(config): State, State(page_view_tx): 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(&db, &slug) .await? .ok_or(AppError::NotFound)?; let response = render_project_page(&db, &config, &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(&page_view_tx, "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( db: &PgPool, config: &Config, db_project: &db::DbProject, csrf_token: Option, maybe_user: Option, ) -> Result { let db_user = db::users::get_user_by_id(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( 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(db, db_project.id).await?; let subscription_tiers: Vec = db_tiers .iter() .map(|t| SubscriptionTier::from_db(t, db_user.settlement_currency)) .collect(); // Show how much is behind the paywall. A project with a lot in it // sells better than a bare price, and the count is not the thing // being sold: the items themselves stay gated. Reads the // denormalized column, so it matches the discover card exactly and // costs one indexed lookup. let item_count = db::projects::get_active_item_count(db, db_project.id) .await .unwrap_or_else(|e| { tracing::warn!( project_id = %db_project.id, error = %e, "paywall item count lookup failed; rendering without a count" ); 0 }); let project = Project::from_db(db_project, item_count.max(0) as u32); let (pwyw_field_min, pwyw_note) = super::pwyw_field_bounds(project_pricing.as_ref(), db_user.settlement_currency); return Ok(ProjectPaywallTemplate { csrf_token, session_user: maybe_user, project, creator_username: db_user.username.to_string(), price_display: project_pricing.price_display(db_user.settlement_currency), checkout_type: project_pricing.checkout_type(), pwyw_min_dollars: pwyw_field_min, pwyw_min_note: pwyw_note, pwyw_currency_code: db_user.settlement_currency.code_upper(), subscription_tiers, host_url: config.host_url.clone(), } .into_response()); } } let db_items = db::items::get_public_items_by_project(db, db_project.id).await?; let is_creator = maybe_user .as_ref() .is_some_and(|u| u.id == db_project.user_id); // Scope the purchase lookup to the items actually rendered on this page // rather than fetching the viewer's entire purchase history (fuzz 2026-07-06 // F6). `purchased_subset` is a single `item_id = ANY($2)` roundtrip. let purchased_item_ids: std::collections::HashSet = if let Some(ref user) = maybe_user { let item_ids: Vec = db_items.iter().map(|i| i.id).collect(); db::transactions::purchased_subset(db, user.id, &item_ids).await? } 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. #[allow( clippy::zero_sized_map_values, reason = "SubscriptionGate is a deliberate zero-sized capability witness, not data" )] let subscribed_gates = if let Some(ref user) = maybe_user { db::subscriptions::SubscriptionGate::subscribed_item_gates(db, user.id).await? } else { std::collections::HashMap::new() }; let has_subscription = if let Some(ref user) = maybe_user { db::subscriptions::has_access( 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(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(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_or(&[][..], std::vec::Vec::as_slice); let mut item = Item::from_db_list( i, item_tags, is_free, can_access, db_user.settlement_currency, ); 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(db, FollowTargetType::Project, db_project.id.into()) .await?; let is_following = if let Some(ref viewer) = maybe_user { db::follows::is_following( db, viewer.id, FollowTargetType::Project, db_project.id.into(), ) .await? } else { false }; let db_tiers = db::subscriptions::get_active_tiers_by_project(db, db_project.id).await?; let subscription_tiers: Vec = db_tiers .iter() .map(|t| SubscriptionTier::from_db(t, db_user.settlement_currency)) .collect(); let git_repos = if config.build.git_repos_path.is_some() { let linked = db::git_repos::get_repos_by_project(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"); Vec::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(db, db_project.id).await?; let community_url = if db_project.mt_community_id.is_some() { config .integrations .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(db, db_project.id).await?; let cdn_base = config.cdn_base_url.as_str(); 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: Vec = db::gallery_images::list_for_project(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"); Vec::default() }) .into_iter() .map(|g| crate::templates::CarouselFrame { // A creator upload: `gallery_images` records a byte count and never // recorded a size, so there is nothing to reserve with. Filed as // the reason galleries still shift while the landing page does not. intrinsic: None, image: g.image_url, alt: if g.alt.trim().is_empty() { format!("{} gallery image", db_project.title) } else { g.alt }, caption: None, }) .collect(); let project_id = db_project.id.to_string(); let creator_id = db_user.id.to_string(); let creator_username = db_user.username.to_string(); let store = crate::quasi::project::Store { project: &project, project_id: &project_id, creator_username: &creator_username, creator_id: &creator_id, host_url: &config.host_url, items: &items, sections: §ions, gallery: &gallery, tiers: &subscription_tiers, git_repos: &git_repos, community_url: community_url.as_deref(), follower_count, is_following, has_subscription, is_owner, signed_in: maybe_user.is_some(), has_blog_posts, tips_enabled: db_user.tips_enabled && db_user.stripe_charges_enabled, }; Ok(axum::response::Html(crate::quasi::project::document( maybe_user.as_ref(), csrf_token.as_deref(), &store, crate::theming::theme_css(db_project.theme_id.as_deref()), )) .into_response()) }