//! Public user, project, and item detail pages. mod item; mod library; mod project; pub(in crate::routes::pages::public) use item::item_page; pub(crate) use item::render_item_page; pub(in crate::routes::pages::public) use library::library_page; pub(in crate::routes::pages::public) use project::project_page; pub(crate) use project::render_project_page; use crate::extractors::ValidatedQuery; use axum::{ extract::{Path, State}, response::{IntoResponse, Redirect, Response}, }; use serde::Deserialize; use sqlx::PgPool; use tower_sessions::Session; use crate::{ auth::{MaybeUserVerified, SessionUser}, config::Config, db::{self, FollowTargetType, ItemId, Username}, error::{AppError, Result}, helpers::get_csrf_token, templates::{BuyPageTemplate, PurchaseTemplate, ReceiptTemplate}, types::{Collection, CustomLink, Item, Project, User}, }; /// 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( page_view_tx: &crate::db::page_views::PageViewTx, target_type: &'static str, target_id: uuid::Uuid, ) { 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(crate) 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(db): State, State(config): State, State(page_view_tx): 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(&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(&db, &config, &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(&page_view_tx, "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( db: &PgPool, config: &Config, db_user: &db::DbUser, csrf_token: Option, maybe_user: Option, ) -> Result { let db_projects = db::projects::get_public_projects_with_item_counts(db, db_user.id).await?; let db_links = db::custom_links::get_custom_links_by_user(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(db, db_user.id).await?; let public_collections: Vec = db_collections.iter().map(Collection::from).collect(); let follower_count = db::follows::get_follower_count(db, FollowTargetType::User, db_user.id.into()).await?; let is_following = if let Some(ref viewer) = maybe_user { db::follows::is_following(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); let user_id = db_user.id.to_string(); let profile = crate::quasi::user::Profile { user: &user, user_id: &user_id, host_url: &config.host_url, custom_links: &custom_links, projects: &projects, collections: &public_collections, follower_count, is_following, is_own_profile, signed_in: maybe_user.is_some(), paused: db_user.is_creator_paused(), tips_enabled: db_user.tips_enabled && db_user.stripe_charges_enabled, }; Ok(axum::response::Html(crate::quasi::user::document( maybe_user.as_ref(), csrf_token.as_deref(), &profile, crate::theming::theme_css(db_user.theme_id.as_deref()), )) .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(db): State, session: Session, MaybeUserVerified(maybe_user): MaybeUserVerified, Path(item_id): Path, ValidatedQuery(query): ValidatedQuery, ) -> 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(&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)?; // Visibility gate, mirror item_page: a draft/deleted/sandbox item's title // and price must not leak to anyone but the owner who holds its UUID. let is_owner = maybe_user .as_ref() .is_some_and(|u| u.id == db_project.user_id); if db_user.is_sandbox && !is_owner { return Err(AppError::NotFound); } if !db_item.is_public && !is_owner { return Err(AppError::NotFound); } if db_item.deleted_at.is_some() && !is_owner { return Err(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); // These render next to a subtotal, so they carry their own symbol rather // than relying on a hardcoded `$` in the template. let stripe_fee = crate::formatting::format_revenue(stripe_fee_cents as i64, db_user.settlement_currency); let creator_receives = crate::formatting::format_revenue( creator_receives_cents as i64, db_user.settlement_currency, ); let purchase_tags = db::tags::get_tags_for_item(&db, id).await?; let item = Item::from_db_list( &db_item, &purchase_tags, price_cents == 0, false, db_user.settlement_currency, ); let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents); // The floor the charge path will accept, not the creator's raw minimum: // a sub-floor minimum here would put an amount in the box that Stripe // refuses after the buyer has typed it. Same rule the project paywall uses. let item_pricing = crate::pricing::for_item(&db_item); let (pwyw_min_dollars, pwyw_min_note) = pwyw_field_bounds(item_pricing.as_ref(), db_user.settlement_currency); let pwyw_min = pwyw_field_min_cents(item_pricing.as_ref(), db_user.settlement_currency); let pending_started = if let Some(ref u) = maybe_user { match db::transactions::get_pending_item_purchase(&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(), currency_symbol: db_user.settlement_currency.symbol(), show_fee_estimate: crate::helpers::stripe_fee_estimate_applies(db_user.settlement_currency), 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, pwyw_min_note, 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(db): 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(&db, tx_id) .await? .ok_or(AppError::NotFound)?; // Only the buyer or the seller can view a receipt. An anonymous viewer must // never match: guest transactions persist `buyer_id = NULL`, so comparing an // `Option` viewer directly (`None == tx.buyer_id`) would let any anonymous // caller read a guest receipt. Require an authenticated viewer, then compare // against the concrete `Some(id)`. let Some(viewer_id) = maybe_user.as_ref().map(|u| u.id) else { return Err(AppError::Forbidden); }; let is_buyer = tx.buyer_id == Some(viewer_id); let is_seller = tx.seller_id == Some(viewer_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 { crate::formatting::format_revenue(amount_cents, tx.currency()) }; // Read before the fields move below. let currency_symbol = tx.currency().symbol(); // Rendered with the presentment currency's own code rather than a symbol: // Stripe presents in 150+ markets, so this can be a currency MNW has no // symbol for, and an ISO code is never wrong. let presented_amount = match ( tx.presentment_amount_cents, tx.presentment_currency.as_deref(), ) { (Some(cents), Some(code)) => format!( "{} {}", crate::formatting::format_dollars_plain(cents), code.to_uppercase() ), _ => String::new(), }; 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, currency_symbol, presented_amount, 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()) } /// 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(db): State, State(config): 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(&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(&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)?; let purchase_tags = db::tags::get_tags_for_item(&db, id).await?; let item = Item::from_db_list( &db_item, &purchase_tags, db_item.price_cents == 0, false, db_user.settlement_currency, ); let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents); let pwyw_min_dollars = crate::formatting::format_dollars_plain(pwyw_field_min_cents( crate::pricing::for_item(&db_item).as_ref(), db_user.settlement_currency, )); Ok(BuyPageTemplate { item, creator_username: db_user.username.to_string(), currency_symbol: db_user.settlement_currency.symbol(), creator_display_name: db_user.display_name.clone(), pwyw_enabled: db_item.pwyw_enabled, pwyw_min_dollars, suggested_price, host_url: config.host_url.clone(), }) } /// What the paywall's amount box will accept: the `min` attribute, and the line /// under it when `min` cannot say the whole rule. /// /// The domain is not always an interval. A project whose creator set no /// minimum accepts a $0 claim *or* a real charge at the settlement currency's /// floor and up, with a hole between them: 25c is neither free nor chargeable. /// `min="0"` alone would wave that through to a server-side refusal after the /// buyer has typed an amount, and `min="0.50"` alone would hide the free claim /// the creator is offering. So the attribute keeps the low end and the note /// carries the hole. /// /// A creator who did set a minimum has no hole: the floor is /// `max(their minimum, the currency's)`, one interval, and the `min` attribute /// states it without help. pub(super) fn pwyw_field_bounds( model: &dyn crate::pricing::PricingModel, currency: crate::currency::SettlementCurrency, ) -> (String, Option) { use crate::formatting::{format_dollars_plain, format_revenue}; let field_min = pwyw_field_min_cents(model, currency); if field_min > 0 || model.checkout_type() != crate::pricing::CheckoutType::PayWhatYouWant { return (format_dollars_plain(field_min), None); } ( format_dollars_plain(0), Some(format!( "Pay {}, or {} and up.", format_revenue(0, currency), format_revenue( i64::from(model.chargeable_minimum_cents(currency)), currency ) )), ) } /// The `min` attribute for a pay-what-you-want amount box, in cents. /// /// Zero for a project or item whose creator set no minimum, because a $0 claim /// is a real outcome there and the attribute is the only thing standing between /// the buyer and it. Otherwise the chargeable floor, which is the creator's /// minimum raised to what the settlement currency will settle. pub(super) fn pwyw_field_min_cents( model: &dyn crate::pricing::PricingModel, currency: crate::currency::SettlementCurrency, ) -> i32 { if model.checkout_type() != crate::pricing::CheckoutType::PayWhatYouWant || model.minimum_cents().unwrap_or(0) <= 0 { return 0; } model.chargeable_minimum_cents(currency) } #[cfg(test)] mod tests { //! The paywall's amount box, whose bounds have to match what the charge //! path will actually accept. A box that accepts an amount checkout then //! refuses spends the buyer's typing and hands back an error that reads as //! the creator's price being wrong. use super::*; use crate::currency::SettlementCurrency::{Gbp, Usd}; use crate::pricing::{PricingModel, PwywPricing}; fn bounds( min_cents: Option, currency: crate::currency::SettlementCurrency, ) -> (String, Option) { pwyw_field_bounds(&PwywPricing { min_cents }, currency) } #[test] fn a_sub_floor_creator_minimum_is_raised_to_what_stripe_settles() { // 25c is a price no card network will move. The box says 50c. assert_eq!(bounds(Some(25), Usd).0, "0.50"); // And the floor is the currency's, not a flat 50: GBP settles at 30p. assert_eq!(bounds(Some(25), Gbp).0, "0.30"); } #[test] fn a_creator_minimum_above_the_floor_is_left_alone() { assert_eq!(bounds(Some(999), Usd).0, "9.99"); assert_eq!(bounds(Some(999), Gbp).0, "9.99"); } #[test] fn a_stated_minimum_needs_no_note_because_min_says_it_all() { assert_eq!(bounds(Some(999), Usd).1, None); assert_eq!(bounds(Some(25), Usd).1, None); } #[test] fn no_minimum_keeps_the_free_claim_and_names_the_hole() { let (min, note) = bounds(None, Usd); assert_eq!(min, "0.00", "a $0 claim must stay reachable"); assert_eq!(note.as_deref(), Some("Pay $0.00, or $0.50 and up.")); } #[test] fn the_note_is_denominated_in_the_creators_currency() { let (_, note) = bounds(Some(0), Gbp); assert_eq!(note.as_deref(), Some("Pay £0.00, or £0.30 and up.")); } #[test] fn a_non_pwyw_project_gets_no_bounds_at_all() { // The box is not drawn for these, so the fields carry inert values // rather than a floor that would be wrong if a template ever read it. let fixed = crate::pricing::FixedPricing { price_cents: 1999 }; assert_eq!(pwyw_field_bounds(&fixed, Usd), ("0.00".to_string(), None)); } #[test] fn the_box_and_the_charge_path_agree_on_every_amount() { // The property the whole fix exists for: the rule the buyer is shown // and the rule the charge path enforces are the same rule. "Shown" // means the attribute plus the note, because the free-claim hole // cannot live in a `min`. Walked in cents either side of both floors. for min_cents in [None, Some(0), Some(1), Some(25), Some(50), Some(120)] { let model = PwywPricing { min_cents }; let (field_min, note) = pwyw_field_bounds(&model, Usd); let field_min_cents = (field_min.parse::().unwrap() * 100.0).round() as i32; let free_claim_offered = note.is_some(); for amount in 0..=200 { let field_accepts = amount >= field_min_cents && !(free_claim_offered && (1..50).contains(&amount)); let charge_accepts = model.validate_amount(amount, Usd).is_ok(); assert_eq!( field_accepts, charge_accepts, "min_cents {min_cents:?}, amount {amount}: box says {field_accepts}, charge path says {charge_accepts}" ); } } } }