//! Public-facing page routes visible to all visitors. pub(crate) mod content; mod discover; mod docs; mod feed; mod health; pub(crate) mod join_wizard; pub(crate) mod landing; mod sitemap; mod two_factor; use axum::{ extract::State, response::{IntoResponse, Redirect}, routing::get, }; use tower_sessions::Session; use crate::{ auth::MaybeUserUnverified, constants, csrf::{post_csrf_skip, with_csrf_skip, CsrfRouter}, db, error::Result, helpers::get_csrf_token, templates::*, types::*, AppState, }; use tower_governor::GovernorLayer; /// Register public page routes. pub fn public_routes() -> CsrfRouter { let twofa_rate_limit = crate::helpers::rate_limiter_ms(constants::TWO_FACTOR_RATE_LIMIT_MS, constants::TWO_FACTOR_RATE_LIMIT_BURST); let join_rate_limit = crate::helpers::rate_limiter_ms(constants::AUTH_RATE_LIMIT_MS, constants::AUTH_RATE_LIMIT_BURST); // Per-IP read limiter for the unauthenticated discover SEARCH endpoints — // these run ILIKE / tag-tree queries per request and are the genuine // DoS-amplification surface among the public GETs (Run #12 Security MINOR). // The cheap, cached content-page GETs (/u, /p, /i, ...) are left to // Cloudflare edge limiting. Burst is generous (API read tier) so legitimate // type-ahead on /discover/suggestions isn't throttled. One shared bucket per // IP across the three search routes. let search_rate_limit = crate::helpers::rate_limiter_ms(constants::API_READ_RATE_LIMIT_MS, constants::API_READ_RATE_LIMIT_BURST); CsrfRouter::new() .route_get("/", get(landing::index)) .route_get("/library", get(landing::library)) .route_get("/cart", get(landing::cart_page)) .route_get("/library/tabs/purchases", get(landing::library_tab_purchases)) .route_get("/library/tabs/feed", get(landing::library_tab_feed)) .route_get("/library/tabs/collections", get(landing::library_tab_collections)) .route_get("/library/tabs/contacts", get(landing::library_tab_contacts)) .route_get("/library/tabs/communities", get(landing::library_tab_communities)) .route_get("/health", get(health::health)) .route_get("/api/health", get(health::health_json)) .route_get("/robots.txt", get(sitemap::robots_txt)) .route_get("/sitemap.xml", get(sitemap::sitemap_xml)) // NOTE: GET /login is registered in auth_routes() alongside POST /login // to avoid Axum merge conflicts that strip rate limiting layers. // Join wizard .route_get("/join", get(join_wizard::wizard_page)) .route( "/join/step/account", post_csrf_skip( "join-wizard step 1: pre-auth signup", join_wizard::step_account_create, ) .layer(GovernorLayer { config: join_rate_limit }), ) .route( "/join/step/{step}", with_csrf_skip( "join-wizard: continuation of pre-auth flow", get(join_wizard::step_load).post(join_wizard::step_save), ), ) .route_get("/discover", get(discover::discover)) .route_get("/discover/results", get(discover::discover_results).layer(GovernorLayer { config: search_rate_limit.clone() })) .route_get("/discover/suggestions", get(discover::search_suggestions_handler).layer(GovernorLayer { config: search_rate_limit.clone() })) .route_get("/discover/tags", get(discover::tag_tree).layer(GovernorLayer { config: search_rate_limit })) .route_get("/feed", get(feed::feed_page)) .route_get("/u/{username}", get(content::user_page)) .route_get("/c/{username}/{slug}", get(content::collection_page)) .route_get("/p/{slug}", get(content::project_page)) .route_get("/i/{item_id}", get(content::item_page)) .route_get("/l/{item_id}", get(content::library_page)) .route_get("/purchase/{item_id}", get(content::purchase_page)) .route_get("/receipt/{transaction_id}", get(content::receipt_page)) .route_get("/buy/{item_id}", get(content::buy_page)) .route_get("/pricing", get(landing::pricing_page)) .route_get("/checkout/complete", get(landing::checkout_complete)) .route_get("/use-cases", get(landing::use_cases_page)) .route_get("/team", get(landing::team_page)) .route_get("/policy", get(landing::policy_page)) .route_get("/fan-plus", get(landing::fan_plus_page)) .route_get("/creators", get(creators_page)) .route_get("/docs", get(docs::docs_index)) .route_get("/docs/search.json", get(docs::docs_search_index)) // Platform economics renders as Askama (live runway disclosure); the // markdown source is gone. Served top-level at /economics alongside the // other landing pages. The old /docs/economics URL 301s here for // continuity and must register BEFORE the catch-all `/docs/{slug}` so // axum prefers the exact match. .route_get("/economics", get(landing::economics_page)) .route_get("/docs/economics", get(|| async { Redirect::permanent("/economics") })) .route_get("/docs/{slug}", get(docs::doc_page)) // Two-factor authentication .route_get("/auth/2fa", get(two_factor::two_factor_page)) .route( "/auth/verify-2fa", post_csrf_skip( "2FA verification: pre-promotion to full auth, no session yet", two_factor::verify_two_factor, ) .layer(GovernorLayer { config: twofa_rate_limit }), ) } /// Render the public creators page showing invite waves and waitlist stats. #[tracing::instrument(skip_all, name = "pages::creators_page")] async fn creators_page( State(state): State, session: Session, MaybeUserUnverified(maybe_user): MaybeUserUnverified, ) -> Result { let csrf_token = get_csrf_token(&session).await; let waves = db::waitlist::get_all_waves(&state.db).await?; let total_creators = db::waitlist::count_active_creators(&state.db).await? as u32; let waitlist_pending = db::waitlist::count_waitlist_pending(&state.db).await? as u32; let is_creator = maybe_user.as_ref().map(|u| u.can_create_projects).unwrap_or(false); let wave_stats: Vec = waves.iter().map(WaveStats::from).collect(); Ok(CreatorsTemplate { csrf_token, session_user: maybe_user, waves: wave_stats, total_creators, waitlist_pending, is_creator, tier_prices: state.tier_prices.clone(), }) }