//! Public user, project, and item detail pages. mod item; mod library; mod project; pub(crate) use item::render_item_page; pub(in crate::routes::pages::public) use item::item_page; pub(in crate::routes::pages::public) use library::library_page; pub(crate) use project::render_project_page; pub(in crate::routes::pages::public) use project::project_page; use axum::{ extract::{Path, Query, State}, response::{IntoResponse, Redirect, Response}, }; use serde::Deserialize; use tower_sessions::Session; use crate::{ auth::{MaybeUserVerified, SessionUser}, db::{self, FollowTargetType, ItemId, Username}, error::{AppError, Result}, helpers::get_csrf_token, templates::*, types::*, AppState, }; /// Fire-and-forget page view recording. Never blocks the response. /// /// Routes through the bounded `PageViewTx` batcher (single bg flush task, /// bulk UPSERT every 500ms) — the prior per-request `tokio::spawn` pattern /// saturated the DB pool under any view burst. pub(crate) fn track_view(state: &crate::AppState, target_type: &'static str, target_id: uuid::Uuid) { state.page_view_tx.try_record(target_type, target_id); } /// Returns true if the User-Agent looks like a bot/crawler. pub(crate) fn is_bot(user_agent: &str) -> bool { let ua = user_agent.to_ascii_lowercase(); ua.contains("bot") || ua.contains("crawler") || ua.contains("spider") || ua.contains("slurp") || ua.contains("facebookexternalhit") || ua.contains("twitterbot") || ua.contains("linkedinbot") || ua.contains("mediapartners") || ua.contains("curl") || ua.contains("wget") || ua.contains("python-requests") } /// Query parameters for the purchase page. #[derive(Debug, Deserialize)] pub struct PurchaseQuery { pub code: Option, } /// Render a public user profile page with projects and custom links. #[tracing::instrument(skip_all, name = "content::user_page")] pub(super) async fn user_page( State(state): State, session: Session, headers: axum::http::HeaderMap, MaybeUserVerified(maybe_user): MaybeUserVerified, Path(username): Path, ) -> Result { let csrf_token = get_csrf_token(&session).await; let username = Username::new(&username).map_err(|_| AppError::NotFound)?; let db_user = db::users::get_user_by_username(&state.db, &username) .await? .ok_or(AppError::NotFound)?; // Sandbox accounts are not publicly visible if db_user.is_sandbox { return Err(AppError::NotFound); } let response = render_user_profile(&state, &db_user, csrf_token, maybe_user).await?; let ua = headers.get(axum::http::header::USER_AGENT) .and_then(|v| v.to_str().ok()) .unwrap_or(""); if !is_bot(ua) { track_view(&state, "user", *db_user.id); } Ok(response) } /// Shared user profile renderer, used by both named routes and custom domain fallback. pub(crate) async fn render_user_profile( state: &AppState, db_user: &db::DbUser, csrf_token: Option, maybe_user: Option, ) -> Result { let db_projects = db::projects::get_public_projects_with_item_counts(&state.db, db_user.id).await?; let db_links = db::custom_links::get_custom_links_by_user(&state.db, db_user.id).await?; let user = User::from(db_user); let projects: Vec = db_projects.iter().map(Project::from).collect(); let custom_links: Vec = db_links.iter().map(CustomLink::from).collect(); let db_collections = db::collections::get_public_collections_by_user(&state.db, db_user.id).await?; let public_collections: Vec = db_collections.iter().map(Collection::from).collect(); let follower_count = db::follows::get_follower_count(&state.db, FollowTargetType::User, db_user.id.into()) .await?; let is_following = if let Some(ref viewer) = maybe_user { db::follows::is_following( &state.db, viewer.id, FollowTargetType::User, db_user.id.into(), ) .await? } else { false }; let is_own_profile = maybe_user.as_ref().is_some_and(|v| v.id == db_user.id); Ok(UserTemplate { csrf_token, session_user: maybe_user, creator_paused: db_user.is_creator_paused(), tips_enabled: db_user.tips_enabled && db_user.stripe_charges_enabled, creator_id: db_user.id.to_string(), tip_project_id: None, user, custom_links, projects, public_collections, user_id: db_user.id.to_string(), is_own_profile, is_following, follower_count, host_url: state.config.host_url.clone(), } .into_response()) } /// Render the purchase confirmation page with fee breakdown. #[tracing::instrument(skip_all, name = "content::purchase_page")] pub(super) async fn purchase_page( State(state): State, session: Session, MaybeUserVerified(maybe_user): MaybeUserVerified, Path(item_id): Path, Query(query): Query, ) -> Result { let csrf_token = get_csrf_token(&session).await; let is_logged_in = maybe_user.is_some(); 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)?; let price_cents = db_item.price_cents; // Free items don't need the purchase page — redirect to item page if price_cents == 0 && !db_item.pwyw_enabled { return Ok(Redirect::to(&format!("/i/{id}")).into_response()); } // Calculate fee breakdown for transparency let (stripe_fee_cents, creator_receives_cents) = crate::helpers::estimate_stripe_fee(price_cents); let stripe_fee = format!("{:.2}", stripe_fee_cents as f64 / 100.0); let creator_receives = format!("{:.2}", creator_receives_cents as f64 / 100.0); let purchase_tags = db::tags::get_tags_for_item(&state.db, id).await?; let item = Item::from_db_list(&db_item, &purchase_tags, price_cents == 0, false); let suggested_price = format!("{:.2}", db_item.price_cents as f64 / 100.0); let pwyw_min = db_item.pwyw_min_cents.unwrap_or(0); let pwyw_min_dollars = format!("{:.2}", pwyw_min as f64 / 100.0); let pending_started = if let Some(ref u) = maybe_user { match db::transactions::get_pending_item_purchase(&state.db, u.id, id).await? { Some((_, created_at)) => format_relative_ago(created_at), None => String::new(), } } else { String::new() }; Ok(PurchaseTemplate { csrf_token, item, creator_username: db_user.username.to_string(), stripe_fee, creator_receives, promo_code: query.code.unwrap_or_default(), pwyw_enabled: db_item.pwyw_enabled, pwyw_min_cents: pwyw_min, suggested_price, pwyw_min_dollars, stripe_tax_enabled: db_user.stripe_tax_enabled, is_logged_in, pending_started, } .into_response()) } fn format_relative_ago(ts: chrono::DateTime) -> String { let delta = chrono::Utc::now().signed_duration_since(ts); let secs = delta.num_seconds().max(0); if secs < 60 { "just now".to_string() } else if secs < 3600 { let m = secs / 60; format!("{m} minute{} ago", if m == 1 { "" } else { "s" }) } else if secs < 86400 { let h = secs / 3600; format!("{h} hour{} ago", if h == 1 { "" } else { "s" }) } else { let d = secs / 86400; format!("{d} day{} ago", if d == 1 { "" } else { "s" }) } } /// Render a purchase receipt page. #[tracing::instrument(skip_all, name = "content::receipt_page")] pub(super) async fn receipt_page( State(state): State, session: Session, MaybeUserVerified(maybe_user): MaybeUserVerified, Path(transaction_id): Path, ) -> Result { let csrf_token = get_csrf_token(&session).await; let tx_id: db::TransactionId = transaction_id.parse().map_err(|_| AppError::NotFound)?; let tx = db::transactions::get_transaction_by_id(&state.db, tx_id) .await? .ok_or(AppError::NotFound)?; // Only the buyer or the seller can view a receipt let viewer_id = maybe_user.as_ref().map(|u| u.id); let is_buyer = viewer_id == tx.buyer_id; let is_seller = viewer_id == tx.seller_id; if !is_buyer && !is_seller { return Err(AppError::Forbidden); } let amount_cents = *tx.amount_cents; let is_free = amount_cents == 0; let amount = if is_free { "Free".to_string() } else { format!("${:.2}", amount_cents as f64 / 100.0) }; let item_id = tx.item_id.map(|id| id.to_string()).unwrap_or_default(); let item_title = tx.item_title.unwrap_or_else(|| "[Deleted item]".to_string()); let seller_username = tx.seller_username.unwrap_or_else(|| "[Deleted user]".to_string()); let date = tx.completed_at .unwrap_or(tx.created_at) .format("%B %d, %Y at %H:%M UTC") .to_string(); Ok(ReceiptTemplate { csrf_token, session_user: maybe_user, transaction_id: tx.id.to_string(), item_id, item_title, seller_username, amount, is_free, status: tx.status.to_string(), date, } .into_response()) } /// Render a public collection page. #[tracing::instrument(skip_all, name = "content::collection_page")] pub(super) async fn collection_page( State(state): State, session: Session, MaybeUserVerified(maybe_user): MaybeUserVerified, Path((username, slug)): Path<(String, String)>, ) -> Result { let csrf_token = get_csrf_token(&session).await; let username = Username::new(&username).map_err(|_| AppError::NotFound)?; let db_user = db::users::get_user_by_username(&state.db, &username) .await? .ok_or(AppError::NotFound)?; let slug = db::Slug::new(&slug).map_err(|_| AppError::NotFound)?; let collection = db::collections::get_collection_by_user_and_slug(&state.db, db_user.id, &slug) .await? .ok_or(AppError::NotFound)?; // Private collections are only visible to the owner let is_owner = maybe_user.as_ref().is_some_and(|u| u.id == db_user.id); if !collection.is_public && !is_owner { return Err(AppError::NotFound); } let db_items = db::collections::get_collection_items(&state.db, collection.id).await?; let items: Vec = db_items.iter().map(CollectionItem::from).collect(); let item_count = items.len() as i64; Ok(CollectionTemplate { csrf_token, session_user: maybe_user, collection: Collection { id: collection.id.to_string(), slug: collection.slug.to_string(), title: collection.title.clone(), description: collection.description.clone(), is_public: collection.is_public, item_count, created_at: collection.created_at.format("%b %d, %Y").to_string(), }, items, owner_username: db_user.username.to_string(), owner_display_name: db_user.display_name.clone(), is_owner, }) } /// Minimal direct purchase page; no navigation chrome, optimized for link-in-bio /// and social media sharing. Shows item summary + guest checkout button. #[tracing::instrument(skip_all, name = "content::buy_page")] pub(super) async fn buy_page( State(state): State, Path(item_id): Path, ) -> Result { 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)?; if !db_item.is_public { return Err(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)?; let purchase_tags = db::tags::get_tags_for_item(&state.db, id).await?; let item = Item::from_db_list(&db_item, &purchase_tags, db_item.price_cents == 0, false); let suggested_price = format!("{:.2}", db_item.price_cents as f64 / 100.0); let pwyw_min = db_item.pwyw_min_cents.unwrap_or(0); let pwyw_min_dollars = format!("{:.2}", pwyw_min as f64 / 100.0); Ok(BuyPageTemplate { item, creator_username: db_user.username.to_string(), creator_display_name: db_user.display_name.clone(), pwyw_enabled: db_item.pwyw_enabled, pwyw_min_dollars, suggested_price, host_url: state.config.host_url.clone(), }) }